Building reusable component packages
kerf has no component runtime — a “component” is just a function that takes props
and returns SafeHtml. 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
Section titled “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:
npm create kerf-component@latest my-widgetscd my-widgetsnpm installnpm run build # tsup → ESM + .d.ts; kerfjs stays externalPass . 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
Section titled “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.
// my-button.tsx — in your component packageimport 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 <button class={`kbtn kbtn-${variant}`} data-action={action}>{label}</button>;}The consumer renders it the same way they’d use a local function:
import { Button } from 'my-kerf-buttons';
mount(root, () => ( <div> <Button label="Add" action="add" /> <Button label="Reset" action="reset" variant="ghost" /> </div>));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
Section titled “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.
// ❌ Shared across ALL <Counter /> instances and ALL apps that import this.import { signal } from 'kerfjs';const count = signal(0);export function Counter() { return <span>{count.value}</span>;}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:
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<typeof createCounter> }): SafeHtml { // `state` is one ReadonlySignal<TState>, so read `state.value.count`. return <span data-action="counter:inc">{store.state.value.count}</span>;}// Consumer — two independent counters.const a = createCounter(0);const b = createCounter(100);mount(root, () => (<><Counter store={a} /><Counter store={b} /></>));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
Section titled “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:
-
Markup + delegation (preferred for most components). The component emits stable hooks (
data-action, a class, anid) and the host wires events at themount()root withdelegate(), 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 theno-inline-jsx-event-handlerslint rule flags them.If your component needs its own wiring, export a companion that the consumer calls once and disposes:
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')!));} -
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-skipand expose a create/dispose pair:export function ChartHost(): SafeHtml {return <div class="kerf-chart" data-morph-skip />;}export function mountChart(hostEl: Element, data: number[]) {const chart = new ThirdPartyChart(hostEl, data);return () => chart.destroy(); // disposer}See the render doc for the full
data-morph-skip/data-morph-skip-children/data-morph-preservesemantics — note that signal-reactive JSX placed directly inside adata-morph-skiphost stops updating, which is exactly why imperative widgets manage their own DOM.
13.4 Packaging
Section titled “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:
{ "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:
{ "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.
Never import kerfjs/dev from a package
Section titled “Never import kerfjs/dev from a package”kerf’s development diagnostics are installed, not inferred: an app writes
if (import.meta.env.DEV) await import('kerfjs/dev') in its own entry, gated on
its own build’s dev flag (see Dev-mode warnings). Those
hooks are process-global — installing them changes defineStore’s get() to
return a read-only proxy and makes a screened URL throw rather than warn, for the
whole application.
That decision belongs to the app. A package that imports the dev entry forces the
diagnostics on every consumer, including in production, where it also drags
~4.7 KB min+gzip into a bundle the consumer thought was dev-free. Keep the import
out of src/ entirely — put it in your own demo page or test harness, which is
where you want the diagnostics while developing the package anyway.
13.5 Publishing
Section titled “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
Section titled “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.
-
kerfjsis apeerDependencyand isexternalin the build (never bundled). - Events go through
delegate()at the host root, or a companionwire(root)that returns a disposer. - Library-owned subtrees use
data-morph-skipplus a create/dispose pair. - Build emits ESM +
.d.ts;tsconfigsetsjsxImportSource: "kerfjs". -
src/never importskerfjs/dev— installing the diagnostics is the consuming app’s call; keep it in your demo/test harness.