On this pageOverview
Foldkit vs React: Side by Side
We built the same pixel art editor (try it live) in both Foldkit and React. Same features, same styling, same algorithms. The goal: compare the two approaches side by side and highlight where they differ. This is a non-trivial app: grid state with undo/redo stacks, three tools with mirror modes, flood fill, localStorage persistence, PNG export, keyboard shortcuts, accessible UI components, and performance-critical grid rendering. It’s the kind of app where architectural decisions compound over time.
The React version uses useReducer, Headless UI, and all the best practices we’d use in production: TypeScript, Tailwind, memoization, custom hooks. We gave React every advantage. The result is a clean, well-structured app. And Foldkit is still better in nearly every way that matters.
Not because React is bad. React is a good library. But Foldkit gives you structural guarantees that React’s component model is architecturally incapable of providing. Those guarantees are what separate a codebase you can maintain with confidence from one you maintain with hope.
Try them both
The Foldkit version is in the examples gallery. The React version source is on GitHub.
Imagine you’ve just joined a team and opened this codebase for the first time. Your first question: what does this app do?
In Foldkit, you read the Message union. 30 declarations. You now know every single thing this application can do.
const PressedCell = m('PressedCell', { x: S.Number, y: S.Number })
const EnteredCell = m('EnteredCell', { x: S.Number, y: S.Number })
const LeftCanvas = m('LeftCanvas')
const ReleasedMouse = m('ReleasedMouse')
const SelectedColor = m('SelectedColor', { colorIndex: PaletteIndex })
const SelectedTool = m('SelectedTool', { tool: Tool })
const SelectedGridSize = m('SelectedGridSize', { size: S.Number })
const ToggledMirrorHorizontal = m('ToggledMirrorHorizontal')
const ToggledMirrorVertical = m('ToggledMirrorVertical')
const ClickedUndo = m('ClickedUndo')
const ClickedRedo = m('ClickedRedo')
const ClickedHistoryStep = m('ClickedHistoryStep', { stepIndex: S.Number })
const ClickedRedoStep = m('ClickedRedoStep', { stepIndex: S.Number })
const ClickedClear = m('ClickedClear')
const SelectedPaletteTheme = m('SelectedPaletteTheme', { themeIndex: S.Number })
const ClickedExport = m('ClickedExport')
const SucceededExportPng = m('SucceededExportPng')
const FailedExportPng = m('FailedExportPng', { error: S.String })
const DismissedErrorDialog = m('DismissedErrorDialog')
const GotErrorDialogMessage = m('GotErrorDialogMessage', {
message: Ui.Dialog.Message,
})
const ConfirmedGridSizeChange = m('ConfirmedGridSizeChange')
const DismissedGridSizeConfirmDialog = m('DismissedGridSizeConfirmDialog')
const GotGridSizeConfirmDialogMessage = m('GotGridSizeConfirmDialogMessage', {
message: Ui.Dialog.Message,
})
const GotToolRadioGroupMessage = m('GotToolRadioGroupMessage', {
message: Ui.RadioGroup.Message,
})
const GotGridSizeRadioGroupMessage = m('GotGridSizeRadioGroupMessage', {
message: Ui.RadioGroup.Message,
})
const GotPaletteRadioGroupMessage = m('GotPaletteRadioGroupMessage', {
message: Ui.RadioGroup.Message,
})
const GotMirrorHorizontalSwitchMessage = m('GotMirrorHorizontalSwitchMessage', {
message: Ui.Switch.Message,
})
const GotMirrorVerticalSwitchMessage = m('GotMirrorVerticalSwitchMessage', {
message: Ui.Switch.Message,
})
const GotThemeListboxMessage = m('GotThemeListboxMessage', {
message: Ui.Listbox.Message,
})
const CompletedSaveCanvas = m('CompletedSaveCanvas')
const Message = S.Union(
PressedCell,
EnteredCell,
LeftCanvas,
ReleasedMouse,
SelectedColor,
SelectedTool,
SelectedGridSize,
ToggledMirrorHorizontal,
ToggledMirrorVertical,
ClickedUndo,
ClickedRedo,
ClickedHistoryStep,
ClickedRedoStep,
ClickedClear,
SelectedPaletteTheme,
ClickedExport,
SucceededExportPng,
FailedExportPng,
DismissedErrorDialog,
GotErrorDialogMessage,
ConfirmedGridSizeChange,
DismissedGridSizeConfirmDialog,
GotGridSizeConfirmDialogMessage,
GotToolRadioGroupMessage,
GotGridSizeRadioGroupMessage,
GotPaletteRadioGroupMessage,
GotMirrorHorizontalSwitchMessage,
GotMirrorVerticalSwitchMessage,
GotThemeListboxMessage,
CompletedSaveCanvas,
)
type Message = typeof Message.Type30 declarations. Each one reads as an English sentence: the user pressed a cell at coordinates x, y. The user selected a tool. The PNG export failed with an error message. The user confirmed a grid size change. The canvas was saved. That file is a complete, readable specification. If it’s not in message.ts, it can’t happen.
The single source of behavior
Every state change in the Foldkit application starts with a Message. Every side effect outcome comes back as a Message. Every UI component interaction flows through a Message. If it’s not a Message, it doesn’t happen.
Now try to answer the same question in React. Since this app uses useReducer, the closest equivalent is the Action type.
type Action =
| Readonly<{ type: 'PressedCell'; x: number; y: number }>
| Readonly<{ type: 'EnteredCell'; x: number; y: number }>
| Readonly<{ type: 'LeftCanvas' }>
| Readonly<{ type: 'ReleasedMouse' }>
| Readonly<{ type: 'SelectedColor'; colorIndex: PaletteIndex }>
| Readonly<{ type: 'SelectedTool'; tool: Tool }>
| Readonly<{ type: 'SelectedGridSize'; size: number }>
| Readonly<{ type: 'ToggledMirrorHorizontal' }>
| Readonly<{ type: 'ToggledMirrorVertical' }>
| Readonly<{ type: 'ClickedUndo' }>
| Readonly<{ type: 'ClickedRedo' }>
| Readonly<{ type: 'ClickedHistoryStep'; stepIndex: number }>
| Readonly<{ type: 'ClickedRedoStep'; stepIndex: number }>
| Readonly<{ type: 'ClickedClear' }>
| Readonly<{ type: 'SelectedPaletteTheme'; themeIndex: number }>
| Readonly<{ type: 'ExportFailed'; error: string }>
| Readonly<{ type: 'DismissedErrorDialog' }>
| Readonly<{ type: 'ConfirmedGridSizeChange' }>
| Readonly<{ type: 'DismissedGridSizeDialog' }>The React Action type has 19 entries. That’s 11 fewer than Foldkit’s Message union. Where are the missing 11?
ClickedExport is missing. Export happens through a useCallback in the App component, not through the reducer. SucceededExportPng and CompletedSaveCanvas are missing. These are Command results that don’t exist in React’s model. And 8 Got*Message variants are missing. RadioGroup focus, Dialog transitions, Switch toggles, and Listbox selection all happen inside Headless UI, invisible to your code.
The React Action type tells you what the reducer handles. Foldkit’s message.ts tells you everything the application does. One is a partial index. The other is a complete specification.
The entry point of an application reveals its architecture. In Foldkit, the entry point is a declaration. In React, it’s a procedure.
The React App component initializes the reducer, computes derived values, installs global event listeners, works around stale closures, memoizes callbacks, and manually threads state into 6 child components:
export const App = () => {
const [state, dispatch] = useReducer(reducer, undefined, createInitialState)
const theme = useMemo(
() => currentPaletteTheme(state.paletteThemeIndex),
[state.paletteThemeIndex],
)
const stateRef = useRef(state)
stateRef.current = state
useKeyboardShortcuts(dispatch)
useMouseRelease(state.isDrawing, dispatch)
useLocalStorage(
state.grid,
state.gridSize,
state.paletteThemeIndex,
state.selectedColorIndex,
state.isDrawing,
)
const handleExport = useCallback(() => {
exportPng(stateRef.current, dispatch)
}, [dispatch])
const currentGrid = useMemo(
() =>
state.isDrawing
? (state.undoStack[state.undoStack.length - 1] ?? state.grid)
: state.grid,
[state.isDrawing, state.undoStack, state.grid],
)
return (
<div className="min-h-screen bg-gray-900 text-gray-100 flex flex-col">
<Header onExport={handleExport} />
<Toolbar
tool={state.tool}
mirrorMode={state.mirrorMode}
selectedColorIndex={state.selectedColorIndex}
gridSize={state.gridSize}
grid={state.grid}
paletteThemeIndex={state.paletteThemeIndex}
theme={theme}
dispatch={dispatch}
/>
<Canvas
grid={state.grid}
gridSize={state.gridSize}
tool={state.tool}
mirrorMode={state.mirrorMode}
hoveredCell={state.hoveredCell}
isDrawing={state.isDrawing}
selectedColorIndex={state.selectedColorIndex}
paletteColors={theme.colors}
dispatch={dispatch}
/>
<HistoryPanel
undoStack={state.undoStack}
redoStack={state.redoStack}
currentGrid={currentGrid}
gridSize={state.gridSize}
theme={theme}
dispatch={dispatch}
/>
<ErrorDialog
isOpen={state.isErrorDialogOpen}
exportError={state.exportError}
dispatch={dispatch}
/>
<ConfirmDialog
isOpen={state.isGridSizeDialogOpen}
pendingGridSize={state.pendingGridSize}
dispatch={dispatch}
/>
</div>
)
}Count the hooks: useReducer, two useMemo, one useRef, one useCallback, plus three custom hooks. That’s 8 hooks in a single component. Every one is required. Remove any of them and something breaks.
The useRef on lines 10–11 deserves special attention. It exists because handleExport is wrapped in useCallback (for memoization), which closes over stale state. So you need a ref to access the current state. This is not a mistake. It’s the standard pattern. React’s closure-based model requires you to manually escape closures when you need current state in a memoized callback.
Then look at the JSX. Every child component receives dispatch as a prop, plus individual slices of state. Toolbar takes 8 props. Canvas takes 9. Each component will need its own useCallback wrappers internally. The prop threading is visible, manual, and exhausting.
The Foldkit entry point declares the initial Model and wires five pieces together:
const init: Runtime.ProgramInit<Model, Message, Flags> = flags => [
{
grid: Option.match(flags.maybeSavedCanvas, {
onNone: () => createEmptyGrid(DEFAULT_GRID_SIZE),
onSome: ({ grid }) => grid,
}),
undoStack: [],
redoStack: [],
tool: 'Brush',
mirrorMode: 'None',
isDrawing: false,
maybeHoveredCell: Option.none(),
toolRadioGroup: Ui.RadioGroup.init({
id: 'tool-picker',
selectedValue: 'Brush',
}),
errorDialog: Ui.Dialog.init({ id: 'export-error-dialog' }),
themeListbox: Ui.Listbox.init({ id: 'theme-picker', selectedItem: '0' }),
// ... all 20 fields initialized declaratively
},
[],
]
const program = Runtime.makeProgram({
Model,
Flags,
flags,
init,
update,
view,
subscriptions,
container: document.getElementById('root')!,
})
Runtime.run(program)No hooks. No refs. No memoized callbacks. No prop threading. The init function returns the initial Model and an empty list of startup Commands. Runtime.makeProgram wires the five pieces together: Model, init, update, view, subscriptions. Runtime.run starts it. The runtime handles event dispatch, memoization, and side effect execution. You declare what the program is. The framework runs it.
In Foldkit, the Model is the complete truth. Every bit of application state lives in one place: the focus position of every RadioGroup, the open/closed state of every Dialog, the checked state of every Switch. You can inspect it, serialize it, send it over the wire, and replay it.
The Foldkit Model uses Effect Schema types with runtime validation, Option<T> instead of null, and explicit fields for every UI component: RadioGroup focus, Dialog transitions, Switch toggles, Listbox selection.
import { Schema as S } from 'effect'
import { Ui } from 'foldkit'
export const Model = S.Struct({
grid: Grid,
undoStack: S.Array(Grid),
redoStack: S.Array(Grid),
selectedColorIndex: PaletteIndex,
gridSize: S.Number,
tool: Tool,
mirrorMode: MirrorMode,
isDrawing: S.Boolean,
maybeHoveredCell: S.OptionFromSelf(Position),
errorDialog: Ui.Dialog.Model,
maybeExportError: S.OptionFromSelf(S.String),
paletteThemeIndex: S.Number,
gridSizeConfirmDialog: Ui.Dialog.Model,
maybePendingGridSize: S.OptionFromSelf(S.Number),
toolRadioGroup: Ui.RadioGroup.Model,
gridSizeRadioGroup: Ui.RadioGroup.Model,
paletteRadioGroup: Ui.RadioGroup.Model,
mirrorHorizontalSwitch: Ui.Switch.Model,
mirrorVerticalSwitch: Ui.Switch.Model,
themeListbox: Ui.Listbox.Model,
})React’s state uses plain TypeScript types (compile-time only, no validation) with null for absent values. The 6 missing fields (RadioGroup focus, Dialog transition state, Switch toggles, Listbox selection) still exist at runtime, but inside Headless UI. They’re invisible to your reducer, your debugger, and your serialization layer.
type State = Readonly<{
grid: Grid
undoStack: ReadonlyArray<Grid>
redoStack: ReadonlyArray<Grid>
selectedColorIndex: PaletteIndex
gridSize: number
tool: Tool
mirrorMode: MirrorMode
isDrawing: boolean
hoveredCell: Position | null
paletteThemeIndex: number
exportError: string | null
isErrorDialogOpen: boolean
pendingGridSize: number | null
isGridSizeDialogOpen: boolean
}>Foldkit’s update function answers a complete question: given this Model and this Message, what is the new Model, and what side effects should happen? It returns [Model, Command[]]. React’s reducer answers half the question: given this state and this action, what is the new state? The other half (what side effects should happen) is scattered across useEffect hooks elsewhere in the codebase.
The update function returns [Model, Command[]]: new state and a list of named side effects. It uses M.tagsExhaustive for exhaustive matching: add a new Message and the compiler forces you to handle it.
export const update = (
model: Model,
message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
M.value(message).pipe(
withUpdateReturn,
M.tagsExhaustive({
PressedCell: ({ x, y }) =>
M.value(model.tool).pipe(
withUpdateReturn,
M.when('Brush', () => [
evo(model, {
grid: () => applyBrush(model, x, y),
undoStack: () => pushHistory(model.undoStack, model.grid),
redoStack: () => [],
isDrawing: () => true,
}),
[],
]),
M.when('Fill', () => {
const nextModel = evo(model, {
grid: () => applyFill(model, x, y),
undoStack: () => pushHistory(model.undoStack, model.grid),
redoStack: () => [],
})
return [nextModel, [saveCanvas(nextModel)]]
}),
// ...
),
ClickedUndo: () =>
Array.match(model.undoStack, {
onEmpty: () => [model, []],
onNonEmpty: nonEmptyUndoStack => {
const nextModel = evo(model, {
grid: () => Array.lastNonEmpty(nonEmptyUndoStack),
undoStack: () => Array.initNonEmpty(nonEmptyUndoStack),
redoStack: () => [...model.redoStack, model.grid],
})
return [nextModel, [saveCanvas(nextModel)]]
},
}),
// ... 28 more handlers
}),
)One function, complete answers
What happens when the user presses a cell? What happens when they undo? You can answer every one of these questions by reading this single function. In Foldkit, that’s the only way to change state. There is no other path.
The reducer returns only the new state. Side effects happen elsewhere in useEffect hooks. The switch/case silently ignores unhandled action types. Add a new action and the compiler won’t catch forgotten cases.
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'PressedCell': {
const { x, y } = action
switch (state.tool) {
case 'Brush':
return {
...state,
grid: applyBrush(state, x, y),
undoStack: pushHistory(state.undoStack, state.grid),
redoStack: [],
isDrawing: true,
}
case 'Fill':
return {
...state,
grid: applyFill(state, x, y),
undoStack: pushHistory(state.undoStack, state.grid),
redoStack: [],
}
// ...
}
}
case 'ClickedUndo': {
if (state.undoStack.length === 0) {
return state
}
const previousGrid = state.undoStack[state.undoStack.length - 1]!
return {
...state,
grid: previousGrid,
undoStack: state.undoStack.slice(0, -1),
redoStack: [...state.redoStack, state.grid],
}
}
// ... 17 more cases
}
}In Foldkit, side effects are Commands: named, typed values that describe work for the runtime to execute. They’re returned from the update function as data. You can see them in Foldkit DevTools, assert on them in tests, and trace exactly which Message caused which effect.
In React, side effects are imperative useEffect hooks that react to state changes. They’re invisible to your reducer, invisible to your debugger, and connected to state only through dependency arrays that the compiler cannot verify.
Commands are defined with Command.define, wrapping an Effect that describes the work. The Command has a name, a return type, and appears in DevTools alongside the Model diff that triggered it.
const SaveCanvas = Command.define('SaveCanvas', CompletedSaveCanvas)
const saveCanvas = (model: Model) =>
SaveCanvas(
Effect.gen(function* () {
const store = yield* KeyValueStore.KeyValueStore
const data: SavedCanvas = {
grid: model.grid,
gridSize: model.gridSize,
paletteThemeIndex: model.paletteThemeIndex,
selectedColorIndex: model.selectedColorIndex,
}
yield* store.set(
STORAGE_KEY,
S.encodeSync(S.parseJson(SavedCanvasSchema))(data),
)
return CompletedSaveCanvas()
}).pipe(
Effect.catchAll(() => Effect.succeed(CompletedSaveCanvas())),
Effect.provide(BrowserKeyValueStore.layerLocalStorage),
),
)A complete inventory of side effects
Every side effect triggered by a state transition must be created with Command.define. Want to know what side effects your Foldkit app produces? Open command.ts. This app has exactly two: SaveCanvas and ExportPng. That’s the complete list. (Subscriptions can also perform lightweight side effects as a consequence of event handling, like preventDefault, but application-level effects always go through Commands.)
The useEffect hook watches for state changes and fires imperatively. There’s no trace connecting the reducer’s state transition to the effect that fires. The effect is a consequence, not a decision.
const useLocalStorage = (
grid: Grid,
gridSize: number,
paletteThemeIndex: number,
selectedColorIndex: PaletteIndex,
isDrawing: boolean,
): void => {
useEffect(() => {
if (isDrawing) {
return
}
try {
const saved: SavedCanvas = {
grid,
gridSize,
paletteThemeIndex,
selectedColorIndex,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved))
} catch {
// Silently fail on storage errors
}
}, [grid, gridSize, paletteThemeIndex, selectedColorIndex, isDrawing])
}Both projects have full test suites covering the same behaviors. The tests verify the same things. But the experience of writing and reading them is not comparable.
React’s reducer tests dispatch actions and assert on the resulting state. That’s it. They have no way to verify which effects should fire, because effects don’t exist in the reducer’s return type. To test side effects, you need a completely different paradigm: render the full <App /> component in jsdom, mock browser APIs, fire DOM events, and poll with vi.waitFor().
Foldkit’s tests tell a different story. Look at the Test.resolve call in the snippet below. It asserts that releasing the mouse produced a SaveCanvas Command, provides the Message that Command will return, and advances the story. The test verifies state and side effects in the same synchronous pipeline. If someone removes the Command, the test breaks. In React, that regression is silent.
Every Foldkit test resolves Commands as part of the story. Not just the “side effect” tests. Every single one.
Test.story() feeds Messages into the update function and inspects both Model and Commands at every step.
test('undo restores the previous grid state', () => {
Test.story(
update,
Test.with(emptyModel),
Test.message(PressedCell({ x: 0, y: 0 })),
Test.message(ReleasedMouse()),
// If someone removes the SaveCanvas command from ReleasedMouse, this
// test fails — you can't accidentally delete a side effect without
// every test that depends on it telling you. That's the point: side
// effects are load-bearing, and your tests enforce it automatically.
Test.resolve(SaveCanvas, CompletedSaveCanvas()),
Test.tap(({ model }) => {
expect(model.grid[0]?.[0]).toEqual(Option.some(0))
expect(model.undoStack).toHaveLength(1)
}),
Test.message(ClickedUndo()),
Test.resolve(SaveCanvas, CompletedSaveCanvas()),
Test.tap(({ model }) => {
expect(model.grid[0]?.[0]).toEqual(Option.none())
expect(model.undoStack).toHaveLength(0)
expect(model.redoStack).toHaveLength(1)
}),
)
})Notice what’s missing from this test: any assertion about localStorage, PNG export, or any other side effect. The reducer can’t see them.
test('undo restores the previous grid state', () => {
const afterPaint = dispatch(
emptyModel,
{ type: 'PressedCell', x: 0, y: 0 },
{ type: 'ReleasedMouse' },
)
expect(afterPaint.grid[0]?.[0]).toBe(0)
expect(afterPaint.undoStack).toHaveLength(1)
const afterUndo = dispatch(afterPaint, { type: 'ClickedUndo' })
expect(afterUndo.grid[0]?.[0]).toBeNull()
expect(afterUndo.undoStack).toHaveLength(0)
expect(afterUndo.redoStack).toHaveLength(1)
})The export test below mocks localStorage, renders the full <App /> component, fires a paint stroke via DOM events, then polls with vi.waitFor() until the async effect completes.
test('painting persists canvas to localStorage', async () => {
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem')
render(<App />)
const cells = findCanvasCells()
const firstCell = cells[0]
// Simulate a paint stroke: mousedown on cell, then mouseup on document
fireEvent.mouseDown(firstCell)
fireEvent.mouseUp(document)
// localStorage.setItem is called inside a useEffect, which runs
// asynchronously after React finishes rendering. We have to poll for it.
await vi.waitFor(() => {
expect(setItemSpy).toHaveBeenCalledWith(
'pixel-art-react-canvas',
expect.any(String),
)
})
})Nothing to mock
Foldkit’s update is a pure function. Side effects are return values, not imperative calls. That means you can test state transitions and side effects together in a unit test with zero mocking, zero DOM, and zero async. In React, testing a side effect means rendering the full component tree in jsdom, mocking browser APIs, firing synthetic events, and polling for async results.
| Foldkit | React | |
|---|---|---|
| State testing | Inspect Model at any point in story | Assert on final state after dispatch |
| Effect testing | Resolve Commands in same pipeline | Separate tests with mocking + DOM |
| Test reads as | Chronological user story | State threading with intermediate variables |
| Catches removed effects | Yes: unresolved Command fails the story | No: reducer tests can’t see effects |
| Infrastructure | Test.story() (built-in, zero dependencies) | @testing-library/react, jsdom, setup file |
| Async | Never: everything is synchronous | Required for useEffect (vi.waitFor) |
Both apps need global event listeners for keyboard shortcuts and mouse release during drawing. Foldkit uses Subscriptions: declarative streams that the runtime manages based on Model state. React uses useEffect hooks with manual setup and cleanup.
Subscriptions are declarative streams. The mouseRelease Subscription conditionally activates based on model.isDrawing. The runtime compares the dependency values on each update and handles subscribe/unsubscribe automatically.
const SubscriptionDeps = S.Struct({
keyboard: S.Null,
mouseRelease: S.Struct({ isDrawing: S.Boolean }),
})
export const subscriptions = Subscription.makeSubscriptions(SubscriptionDeps)<
Model,
Message
>({
keyboard: {
modelToDependencies: () => null,
dependenciesToStream: () =>
Stream.fromEventListener<KeyboardEvent>(document, 'keydown').pipe(
Stream.mapEffect(handleKeyboardEvent),
Stream.filterMap(Function.identity),
),
},
mouseRelease: {
modelToDependencies: model => ({ isDrawing: model.isDrawing }),
dependenciesToStream: ({ isDrawing }) =>
Stream.when(
Stream.fromEventListener(document, 'mouseup').pipe(
Stream.map(() => ReleasedMouse()),
),
() => isDrawing,
),
},
})useEffect hooks achieve the same result with manual setup, cleanup, and dependency arrays. Miss a dependency and you get stale closures. Foldkit has no closures to go stale. The view and Subscriptions always receive the current Model.
const useKeyboardShortcuts = (dispatch: React.Dispatch<Action>): void => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isModifier = event.metaKey || event.ctrlKey
if (isModifier && event.shiftKey && event.key === 'z') {
event.preventDefault()
dispatch({ type: 'ClickedRedo' })
return
}
if (isModifier && event.key === 'z') {
event.preventDefault()
dispatch({ type: 'ClickedUndo' })
return
}
// ...
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [dispatch])
}
const useMouseRelease = (
isDrawing: boolean,
dispatch: React.Dispatch<Action>,
): void => {
useEffect(() => {
if (!isDrawing) {
return
}
const handleMouseUp = () => {
dispatch({ type: 'ReleasedMouse' })
}
document.addEventListener('mouseup', handleMouseUp)
return () => document.removeEventListener('mouseup', handleMouseUp)
}, [isDrawing, dispatch])
}Foldkit ships accessible UI components (Dialog, RadioGroup, Switch, Listbox) that work like everything else in Foldkit: each has a Model, Messages, and an update function. You initialize them in your Model, delegate their Messages in your update, and compose their views. The state is yours. React uses Headless UI, which provides the same accessible patterns through a component API. But the state is theirs.
| Foldkit | React + Headless UI | |
|---|---|---|
| State | Yours: in the Model, visible, serializable | Theirs: internal, invisible, not serializable |
| Events | Messages delegated through your update | Callbacks (onChange, onClose) |
| Accessibility | Built-in (aria, focus, keyboard) | Built-in (aria, focus, keyboard) |
| Debugging | Full state visible in DevTools | Component internals hidden from DevTools |
We profiled both production builds painting across a 32×32 grid (1024 cells) in Chrome. React averages ~16.5ms per frame. Foldkit averages ~16.7ms. Both render at 60fps. The result is the same. The developer experience is not.
Declare memoization helpers at the module level. Pass model data in. The arguments are compared by reference, and evo() preserves references for unchanged fields, so the check passes naturally for panels whose data hasn’t changed.
const lazyHeader = createLazy()
const lazyToolPanel = createLazy()
const lazyHistoryPanel = createLazy()
const lazyRow = createKeyedLazy()
export const view = (model: Model): Html =>
div(
[],
[
lazyHeader(headerView, []),
lazyToolPanel(toolPanelView, [
model.mirrorMode,
model.selectedColorIndex,
isGridEmpty(model.grid),
model.toolRadioGroup,
// ...
]),
canvasView(model, theme),
lazyHistoryPanel(historyPanelView, [
model.undoStack,
model.redoStack,
currentGrid,
model.gridSize,
theme,
]),
],
)createLazy() and createKeyedLazy() compare arguments by reference. The key: the arguments are pure data (model fields, primitive values), not callback functions. There are no closures at the memoization boundary, so there’s nothing to stabilize.
Wrap every component in memo. Wrap every handler in useCallback. Wrap every derived value in useMemo. Thread dispatch through every component.
export const App = () => {
const [state, dispatch] = useReducer(reducer, undefined, createInitialState)
const theme = useMemo(
() => currentPaletteTheme(state.paletteThemeIndex),
[state.paletteThemeIndex],
)
const handleExport = useCallback(() => {
exportPng(stateRef.current, dispatch)
}, [dispatch])
const currentGrid = useMemo(
() =>
state.isDrawing
? (state.undoStack[state.undoStack.length - 1] ?? state.grid)
: state.grid,
[state.isDrawing, state.undoStack, state.grid],
)
return (
<div>
<Header onExport={handleExport} />
{/* Each child is wrapped in memo() and receives dispatch + state slices */}
<Toolbar
tool={state.tool}
mirrorMode={state.mirrorMode}
dispatch={dispatch}
/>
<Canvas grid={state.grid} gridSize={state.gridSize} dispatch={dispatch} />
<HistoryPanel undoStack={state.undoStack} dispatch={dispatch} />
</div>
)
}
// Every component must be wrapped in memo() to avoid re-rendering
const Header = memo(function Header({ onExport }: { onExport: () => void }) {
// ...
})
const Toolbar = memo(function Toolbar({
tool,
mirrorMode,
dispatch,
}: ToolbarProps) {
// useCallback for every handler inside
})
const Canvas = memo(function Canvas({ grid, gridSize, dispatch }: CanvasProps) {
// useCallback for every handler inside
})
const HistoryPanel = memo(function HistoryPanel({
undoStack,
dispatch,
}: HistoryProps) {
// useCallback for every handler inside
})React’s React.memo also uses reference equality. But React props include callback functions, which are new references on every render. So you need useCallback to stabilize them, and useMemo for derived values. Forget one and the optimization silently breaks.
The detailed comparisons above tell the story section by section. Here’s the summary: what Foldkit guarantees by construction that React cannot.
message.ts is a complete, readable specification of every state change, side effect outcome, and UI component interaction. There is no equivalent in React.
Add a new Tool variant or a new Message and TypeScript’s exhaustive match (M.tagsExhaustive) shows you every place in the update function that needs to handle it. In React, adding a new action type to the reducer is only part of the job. You still have to find every useEffect, every component callback, and every custom hook that might need updating. Nothing connects them.
Commands have names, appear in DevTools alongside the Model diff, and can be asserted on in tests. React’s useEffect hooks are invisible to the reducer and invisible to debugging tools.
Every bit of state lives in the Model, including UI component internals (focus position, transition frames, checked state). Foldkit DevTools can step through the complete history. React’s DevTools show the current component tree, but Headless UI’s internal state is unreachable.
Foldkit’s Test.story tests side effects for free with no mocking. Remove a Command and the test breaks. In React, testing side effects requires a separate paradigm: rendering in jsdom, mocking browser APIs, and polling for async results.
A bug in Foldkit lives in the update function. That’s the only place state changes. In React, the same bug could be in the reducer, a useEffect, a useCallback, a custom hook, or inside Headless UI. You have to check all of them.
No dependency arrays. No useCallback wrappers. No refs to escape closures. The entire class of bugs that comes from React’s closure-based model does not exist in Foldkit.
A pixel art editor is a non-trivial app. But real products don’t stop here. What happens when the feature set grows? Consider what it would take to add remote persistence, multiplayer editing, or an undo/redo timeline that survives a page refresh.
In Foldkit, you’d define a SyncToServer Command and return it from the update function. When the user finishes a paint stroke, the ReleasedMouse handler returns both SaveCanvas and SyncToServer. Both side effects are visible in one place, appear in DevTools, and your tests verify they were triggered by the right Message. In React, you’d add another useEffect that watches for state changes and fires a network request. Now you have two independent effects (localStorage and remote sync) that can race, and neither is visible in the reducer.
Foldkit’s Model is serializable by design. Every field uses Effect Schema types with runtime validation. Sending the Model over a WebSocket and applying remote Messages through the same update function is architecturally trivial: the update function already handles every possible state transition. Remote Messages go through the same pipeline as local ones.
In React, Headless UI’s internal state (focus position, transition frames, dialog open/close) can’t be serialized or sent over the wire. You’d need to rebuild UI component state on the receiving end, which means your multiplayer sync and your local UI are working with different sources of truth.
Imagine turning this into a frame-by-frame animation tool. You paint multiple grids, arrange them in a timeline, and play them back. In Foldkit, the new state is straightforward: add a frames: Array<Grid> field, a currentFrameIndex, and an isPlaying boolean to the Model. Playback is a Subscription that emits AdvancedFrame Messages on a timer when isPlaying is true. The update function handles AdvancedFrame by incrementing the index. The view renders the current frame. All of it flows through the existing architecture.
In React, the frame sequence lives in the reducer. But playback needs a useEffect with a setInterval and cleanup. The interval callback closes over stale state, so you need a useRef for the current frame index. Now you have the same state in two places: the reducer (source of truth) and the ref (for the closure). The existing localStorage useEffect and the playback useEffect both respond to state changes, and neither knows about the other. Every new real-time feature adds another coordination problem.
Foldkit’s undo stack is part of the Model. Persisting it to IndexedDB is another Command. Restoring it on page load is part of init. The entire round-trip (save, restore, resume) flows through the same architecture with no special cases. React’s undo stack lives in useReducer state, but persisting it means coordinating another useEffect with the existing localStorage effect, managing serialization of the grid arrays, and ensuring the two effects don’t step on each other.
The pattern is always the same: Foldkit’s architecture scales by adding Messages and Commands to existing functions. React’s architecture scales by adding more hooks, more effects, and more coordination between them. The first approach compounds clarity. The second compounds complexity.
React is a good library with a massive ecosystem. We’re not pretending otherwise.
But look at what we built. The same app, the same features, the same styling. In React, understanding the app requires reading the reducer, the hooks, the components, and the Headless UI docs. In Foldkit, you open message.ts to see every state change. You open command.ts to see every side effect. Two files. Complete picture.
If you care about adding features without fear, onboarding new developers by pointing them at one file, debugging production issues by replaying state, and trusting that your test suite actually catches regressions, Foldkit gives you things React structurally cannot.