# 13. Building reusable component packages kerf has no component runtime — a "component" is just a function that takes props and returns [`SafeHtml`](6-jsx-runtime.md). That makes shipping reusable components as npm packages straightforward: a package exports plain functions, the consumer imports and renders them like any local component. **Nothing in the runtime prevents this**, and no extra build step is required beyond what a kerf app already has. This doc covers how to author such a package, the considerations that are unique to kerf's no-instance model (state, events, cleanup, library-owned DOM), and how to set up and publish the package — using the in-repo `eslint-plugin-kerfjs` package as the sibling-package model. ## 13.0 Quick start: scaffold with `create-kerf-component` The fastest way to a correctly-configured package is the `create-kerf-component` initializer — it generates a package that already follows every rule below (`kerfjs` as a peer dependency and `external` in the build, ESM + `.d.ts` output, `jsxImportSource: "kerfjs"`, subpath exports) plus an example component that shows the per-instance-state and `wire(root)`-disposer patterns: ```bash npm create kerf-component@latest my-widgets cd my-widgets npm install npm run build # tsup → ESM + .d.ts; kerfjs stays external ``` Pass `.` to scaffold into the current directory. The rest of this doc explains *why* the generated package is shaped the way it is — read on if you're authoring by hand or want to understand the rules the scaffold encodes. ## 13.1 What a component is A component is a function `(props) => SafeHtml`. The JSX runtime calls it directly when it sees a function-valued tag (`src/jsx-runtime.ts`: `if (typeof tag === 'function') return tag(props)`), inlining the returned `SafeHtml` into the parent markup. ```tsx // my-button.tsx — in your component package import type { SafeHtml } from 'kerfjs'; export interface ButtonProps { label: string; /** A delegation hook, NOT an inline handler — see §13.3. */ action: string; variant?: 'primary' | 'ghost'; } export function Button({ label, action, variant = 'primary' }: ButtonProps): SafeHtml { return ; } ``` The consumer renders it the same way they'd use a local function: ```tsx import { Button } from 'my-kerf-buttons'; mount(root, () => (
)); ``` There is no instance, no lifecycle, and no per-component state — the function runs on every render of the host `mount()`. Everything a component "remembers" must live outside it (see §13.2). ## 13.2 State: the one thing to get right Because a component is a plain function, **any state must live outside it** — in a signal or store. The trap is module scope: a signal declared at the top of a component module is a *singleton*, shared by every render and every consumer of that module. ```tsx // ❌ Shared across ALL instances and ALL apps that import this. import { signal } from 'kerfjs'; const count = signal(0); export function Counter() { return {count.value}; } ``` That is correct for genuinely global state (a theme toggle, a toast queue) and wrong for anything that should be per-instance. For per-instance state, export a **factory** that creates the state and have the component read it from props: ```tsx import { defineStore, type SafeHtml } from 'kerfjs'; export function createCounter(start = 0) { return defineStore({ initial: () => ({ count: start }), // `actions` is a builder `(set, get) => ({...})` — not an object of reducers. actions: (set, get) => ({ inc: () => set({ count: get().count + 1 }) }), }); } export function Counter({ store }: { store: ReturnType }): SafeHtml { // `state` is one ReadonlySignal, so read `state.value.count`. return {store.state.value.count}; } ``` ```tsx // Consumer — two independent counters. const a = createCounter(0); const b = createCounter(100); mount(root, () => (<>)); ``` The rule of thumb: **a reusable component should never own per-instance mutable module state.** Accept signals/stores via props, or hand the consumer a factory. ## 13.3 Events and cleanup Components are pure string-builders, so they can't attach listeners or register an `effect()` and clean it up themselves — there is no lifecycle hook to run teardown. Two patterns cover the cases: 1. **Markup + delegation (preferred for most components).** The component emits stable hooks (`data-action`, a class, an `id`) and the *host* wires events at the `mount()` root with [`delegate()`](5-event-delegation.md), which returns a disposer. This survives re-renders because the listener lives on the root, not on the (re-rendered) component nodes. Never use inline JSX event handlers (`onClick={...}`) — they don't survive the morph, and the `no-inline-jsx-event-handlers` lint rule flags them. If your component needs its own wiring, export a companion that the consumer calls once and disposes: ```ts import { delegate } from 'kerfjs'; /** Returns a disposer — call it on teardown. */ export function wireButtons(root: HTMLElement, onAction: (a: string) => void) { return delegate(root, 'click', '[data-action]', (e, el) => onAction(el.getAttribute('data-action')!)); } ``` 2. **Imperative widget (for wrapping third-party libraries).** When the component owns a subtree kerf must not touch — a chart, an editor, a map — render an empty host marked [`data-morph-skip`](4-render.md) and expose a create/dispose pair: ```tsx export function ChartHost(): SafeHtml { return
; } export function mountChart(hostEl: Element, data: number[]) { const chart = new ThirdPartyChart(hostEl, data); return () => chart.destroy(); // disposer } ``` See the [render doc](4-render.md) for the full `data-morph-skip` / `data-morph-skip-children` / `data-morph-preserve` semantics — note that signal-reactive JSX placed *directly inside* a `data-morph-skip` host stops updating, which is exactly why imperative widgets manage their own DOM. ## 13.4 Packaging The single most important rule: **declare `kerfjs` (and any other shared runtime) as a `peerDependency`, and never bundle it into your package.** A component returns `SafeHtml` and reads signals; both rely on the consumer and your package agreeing on *one* `SafeHtml` class and *one* signals instance. If your package bundled its own copy of kerfjs, brand checks like `isSafeHtml` and signal identity would silently break across the boundary — the same class-duplication hazard the in-repo `tests/dist/safe-html-cross-bundle.test.ts` guards against. Keep kerfjs external. A minimal `package.json`, mirroring `eslint-plugin/package.json`: ```jsonc { "name": "my-kerf-buttons", "version": "0.1.0", "type": "module", "license": "MIT", "peerDependencies": { "kerfjs": ">=0.14.0" }, "devDependencies": { "kerfjs": "^0.14.0", "tsup": "^8", "typescript": "^5" }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "files": ["dist", "README.md", "LICENSE"], "scripts": { "build": "tsup src/index.ts --format esm --dts --external kerfjs" } } ``` The package's own `tsconfig.json` needs the same JSX wiring any kerf app uses, so the author's `.tsx` compiles against kerf's runtime: ```jsonc { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "kerfjs" } } ``` **Consumers need no extra setup.** Your package ships compiled JS whose internal JSX is already lowered to `kerfjs/jsx-runtime` calls. A consumer who already has a working kerf app (`jsxImportSource: "kerfjs"`) can `import { Button } from 'my-kerf-buttons'` and use it immediately — there's no component-specific toolchain to install. ## 13.5 Publishing The repo publishes `kerfjs`, `eslint-plugin-kerfjs`, and `create-kerf-component` from a single git tag in lockstep — not a workspace monorepo, just sibling directories each with their own `package.json`, `package-lock.json`, and a dedicated CI workflow (e.g. `.github/workflows/release-eslint-plugin.yml`, `release-create-kerf-component.yml`) gated on an npm Trusted-Publisher environment. A third-party component package follows the same shape: build with `tsup`, emit ESM + `.d.ts`, publish with npm provenance. There is no npm org/scope requirement — all three publish unscoped. ## 13.6 Checklist - [ ] Components are functions `(props) => SafeHtml`; no inline event handlers. - [ ] No per-instance state in module scope — accept signals/stores via props, or export a factory. - [ ] `kerfjs` is a `peerDependency` and is `external` in the build (never bundled). - [ ] Events go through `delegate()` at the host root, or a companion `wire(root)` that returns a disposer. - [ ] Library-owned subtrees use `data-morph-skip` plus a create/dispose pair. - [ ] Build emits ESM + `.d.ts`; `tsconfig` sets `jsxImportSource: "kerfjs"`.