Skip to content

Coming from React

You wrote a React app. You’re reading this because the bundle is bigger than you wanted, or you want to see what “no virtual DOM” actually feels like, or your AI assistant kept hallucinating hooks. This page translates the same TodoMVC — store, keyed list, persistence, focus survival on the new-todo input — from React 19 to kerf, section by section.

The kerf side is the exact code shipping at site/src/examples/complete/todomvc/run it live and you’re looking at the same bytes the snippets below show.

Min + gz, runtime only
react + react-dom 19.2~45 KB
kerfjs 0.5 (incl. signals)~6.5 KB
Delta~38 KB lighter

The trade you’re making: virtual DOM and the hooks scheduler go away. JSX still works (it compiles to HTML strings, not virtual nodes), signal/computed/effect replace useState/useMemo/useEffect, and each(items, render, key) replaces .map(item => <Row key={item.id} ... />). There are no components — function calls return JSX directly.

ReactKerfNotes
useState(initial)signal(initial)Module-scoped, not per-component. Read with s.value, write with s.value = ....
useMemo(fn, deps)computed(fn)Dependencies are auto-tracked — no deps array.
useEffect(fn, deps)effect(fn)Auto-tracked. Returns an unsubscribe function instead of taking a cleanup return.
useReducer / ContextdefineStore({ initial, actions })One store, named actions, no provider tree.
useRef (for focus)usually unnecessaryThe morph preserves focus + selection on the input being typed into.
<Component />plain function returning JSXNo instances, no props object — pass arguments directly.
items.map((it) => <Row key={it.id} ... />)each(items, (it) => <Row ... />, (it) => it.id)The third arg is the key function. Listing rows without each loses focus on reorder.
onClick={fn} on the JSX nodedelegate(root, 'click', '[data-action="..."]', fn)One listener at the root, matched by selector. Survives re-render.
key propdata-key={item.id} and the third arg to eachThe DOM attribute keys the morph; the function keys each’s per-row memo.
React.memo(Component)per-row memoization is automatic in eachThe render function is skipped when the item identity (+ key) is unchanged.
useEffect(() => cleanup)const stop = effect(fn); stop()The return value is the cleanup.
Strict Mode double-invocationn/amount’s render function runs once per change.

The same TodoMVC, section by section. Each kerf block matches site/src/examples/complete/todomvc/main.tsx line for line — click Run live above to see it running.

// React
import { useState, useEffect } from 'react';
interface Todo { id: string; text: string; done: boolean }
type Filter = 'all' | 'active' | 'done';
const STORAGE_KEY = 'react-todomvc';
function load(): Todo[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Todo[]) : [];
} catch { return []; }
}
function App() {
const [items, setItems] = useState<Todo[]>(load);
const [filter, setFilter] = useState<Filter>('all');
const [editingId, setEditingId] = useState<string | null>(null);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}, [items]);
// ...
}
// Kerf
import { defineStore, mount, each, delegate, delegateCapture, effect } from 'kerfjs';
interface Todo { id: string; text: string; done: boolean }
type Filter = 'all' | 'active' | 'done';
const STORAGE_KEY = 'kerf-todomvc';
function load(): Todo[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Todo[]) : [];
} catch { return []; }
}
const todos = defineStore({
initial: () => ({ items: load(), filter: 'all' as Filter, editingId: null as string | null }),
actions: (set, get) => ({
add: (text: string) => { /* ... */ },
toggle: (id: string) => { /* ... */ },
// ...
}),
});
effect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos.state.value.items));
});

What moved: React’s three useState calls collapse into one defineStore with three keys. The useEffect that writes to localStorage becomes a top-level effect — no deps array, no component lifecycle. load() is the same in both.

// React
return (
<div className="todoapp">
<header>
<h1>todos</h1>
<input
className="new-todo"
placeholder="What needs to be done?"
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
const input = e.currentTarget;
setItems([...items, { id: crypto.randomUUID(), text: input.value, done: false }]);
input.value = '';
}}
autoFocus
/>
</header>
{/* list goes here */}
</div>
);
// Kerf
mount(root, () => {
const { items, filter, editingId } = todos.state.value;
return (
<div class="todoapp">
<header>
<h1>todos</h1>
<input class="new-todo" data-new placeholder="What needs to be done?" autofocus />
</header>
{/* list goes here */}
</div>
);
});

What moved: classNameclass, autoFocusautofocus (kerf uses the HTML attribute name, not the React DOM property name). The onKeyDown inline handler moves out of the JSX — see §3d. mount(root, () => ...) replaces React’s createRoot(root).render(<App />); the function passed to mount re-runs whenever any signal it reads changes, like a component-shaped useEffect whose dependencies are auto-tracked.

// React
<ul className="todo-list">
{items
.filter((it) => filter === 'active' ? !it.done : filter === 'done' ? it.done : true)
.map((todo) => (
<li
key={todo.id}
className={`${todo.done ? 'done' : ''} ${editingId === todo.id ? 'editing' : ''}`}
>
{editingId === todo.id ? (
<input className="edit" defaultValue={todo.text} autoFocus />
) : (
<>
<input type="checkbox" checked={todo.done} onChange={() => toggle(todo.id)} />
<label onDoubleClick={() => setEditingId(todo.id)}>{todo.text}</label>
<button onClick={() => remove(todo.id)}>×</button>
</>
)}
</li>
))}
</ul>
// Kerf
<ul class="todo-list">
{each(
items.filter((it) =>
filter === 'active' ? !it.done : filter === 'done' ? it.done : true,
),
(todo) => (
<li
data-key={todo.id}
class={`${todo.done ? 'done' : ''} ${editingId === todo.id ? 'editing' : ''}`}
>
{editingId === todo.id ? (
<input class="edit" data-edit data-id={todo.id} value={todo.text} autofocus />
) : (
<>
<input type="checkbox" class="toggle" data-action="toggle" data-id={todo.id} checked={todo.done} />
<label data-action="edit" data-id={todo.id}>{todo.text}</label>
<button class="destroy" data-action="remove" data-id={todo.id}>×</button>
</>
)}
</li>
),
(todo) => `${todo.id}-${editingId === todo.id ? 'edit' : 'view'}`,
)}
</ul>

What moved: items.mapeach(items, render, key). The third argument — the key function — is what each uses to memoize each row’s HTML output between renders; rows whose key is unchanged are pulled from cache and never re-rendered. The data-key={todo.id} on the <li> is the DOM key the morph uses to identify the row across renders (so insert/delete don’t blur the focused element). React’s single key prop does both jobs; kerf splits them because the row-cache key sometimes needs to encode mode (e.g. view vs edit) while the DOM-identity key stays stable.

Inline onChange/onClick/onDoubleClick handlers are replaced by data-action attributes; the real handler is registered once on the root in §3d.

// React — handlers are inline on every JSX node, recreated each render
<input type="checkbox" checked={todo.done} onChange={() => toggle(todo.id)} />
<button onClick={() => remove(todo.id)}>×</button>
<label onDoubleClick={() => setEditingId(todo.id)}>{todo.text}</label>
// new-todo Enter handler also lives in JSX (see §3b)
// Kerf — handlers register once, at module load, on the root
delegate(root, 'click', '[data-action="toggle"]', (_e, el) => {
todos.actions.toggle((el as HTMLElement).dataset.id!);
});
delegate(root, 'click', '[data-action="remove"]', (_e, el) => {
todos.actions.remove((el as HTMLElement).dataset.id!);
});
delegate(root, 'click', '[data-action="edit"]', (_e, el) => {
todos.actions.startEdit((el as HTMLElement).dataset.id!);
});
delegate(root, 'keydown', '[data-new]', (e, el) => {
if ((e as KeyboardEvent).key !== 'Enter') return;
const input = el as HTMLInputElement;
todos.actions.add(input.value);
input.value = '';
});
// Tier 2: blur doesn't bubble — capture phase is required.
delegateCapture(root, 'blur', '[data-edit]', (_e, el) => {
const input = el as HTMLInputElement;
if (todos.state.value.editingId === input.dataset.id) {
todos.actions.commitEdit(input.dataset.id!, input.value);
}
});

What moved: every per-node handler collapses into one delegate(root, type, selector, fn) call that survives every re-render. Blur — which doesn’t bubble — uses delegateCapture (Tier 2 in kerf’s listener model). React’s synthetic event system handled bubblers and non-bubblers uniformly; in kerf you pick the tier explicitly, which is two more lines per non-bubbler and a lot fewer event-system bytes.

In React: every keystroke in the new-todo input causes a re-render. The input itself is preserved because it has a stable position in the JSX, but if you’re not careful (e.g. the input renders inside a list whose order is changing), focus drops. The common React fix is useRef + manual .focus() restoration in a useEffect.

In kerf: focus + caret position + selection range on the currently-focused input are saved before the morph and restored after. You don’t write the code. This is the morph’s job, and it stays out of your way. Try it in the live TodoMVC: type into the new-todo while items are added, toggled, deleted, reordered, filtered — your caret never moves.

<MyComponent /> is sugar for a function call, not a component instance. Writing <MyComponent props /> works — the JSX runtime calls MyComponent(props) and uses the returned JSX — but there’s no instance state, no hooks, no lifecycle. The function takes its props and returns JSX; that’s it. If you find yourself reaching for useState inside a child component, the value goes in a module-level signal or a defineStore instead. The mental adjustment is from “components own state” to “modules own state, functions render it.”

No closure-capture footgun on event handlers. React’s useEffect famously captures stale state unless you list every read in the deps array. effect() in kerf auto-tracks; you never list deps. The flip side: effect() re-runs the entire function whenever any signal it reads changes, so don’t pile unrelated work into one effect.

Refs are usually unnecessary. useRef for “I need to focus this element after render” or “I need to read this DOM property” is almost always unneeded — the morph preserves focus, and you can read DOM state in your delegate handler from the el argument. The exception is integrating a non-kerf library (a chart, an editor) that needs a stable DOM target; in that case wrap its mount point in data-morph-skip so the morph leaves the subtree alone.

onChange semantics differ. React’s onChange fires on every keystroke (it’s actually input); kerf uses real DOM events. If you want every-keystroke behavior, listen for 'input'; if you want commit-on-blur-or-enter, listen for 'change' (which doesn’t bubble — delegateCapture it).

No Strict Mode double-invocation. React 19’s dev-mode double-render of effects catches bugs that come from React’s own reconciliation model; kerf doesn’t have that reconciliation model, so it doesn’t need the double-invocation. Your effect() runs once per change.

useEffect cleanup → effect() return value. React expects you to return a cleanup function from useEffect. Kerf’s effect() returns an unsubscribe function: const stop = effect(...); stop() cancels the subscription. You won’t need this for most app code (effects live for the app’s lifetime), but if you do, the shape is different.

Class vs className. Kerf JSX uses HTML attribute names — class, for, tabindex, autofocus — not React’s className, htmlFor, tabIndex, autoFocus. Same with SVG: stroke-width, not strokeWidth.

Event handlers are not JSX props. onClick={fn} on a JSX node will render as onclick="fn" and break — it’ll either throw at template-compile time or render the handler’s source code into the HTML string. Use delegate(root, 'click', selector, fn) instead.

krausest js-framework-benchmark, medians of 3 iterations, ms — lower is better. Pulled from bench/results.md.

OpReact 19.2 (hooks)Kerf 0.5Δ
create 1k40.946.1kerf ~13 % slower
partial update24.144.6kerf ~85 % slower
swap rows157.322.3kerf ~7× faster
select row8.027.6kerf ~3.5× slower
remove row18.017.0kerf marginally faster
append 1k48.850.5wash
clear 1k26.718.6kerf ~30 % faster

Where kerf wins: anything that exercises the LIS-based move pass (swap rows) or the keyed-list reconciler’s bulk parse (clear, remove). Where React wins: select-row and partial-update are where React’s reconciler’s hash-keyed per-row work pays off vs kerf’s per-render Map+LIS overhead.

Worth knowing: React’s swap-rows 157ms is the cost of React 19 specifically reconciling a list when two non-adjacent items swap — that single op alone is probably enough to motivate a rewrite if your app does any reordering. Solid (6.5 select-row, 21.9 swap-rows) is the framework that wins both columns; if you’re shopping for the absolute fastest and you want a real component framework, look at Solid first.

See the full bench table →