On this pageOverview
View
The view function turns your Model into HTML. The user doesn’t see the Model directly. They see what view renders from it.
In the restaurant analogy, the waiter’s notebook says “table 3: salmon, ready.” The view is what’s actually on the table: the plate in front of the customer.
Given the same Model, view always produces the same HTML. It never modifies state directly. Instead, it dispatches Messages through event handlers, feeding them back into the loop.
import { type Document, html } from 'foldkit/html'
// VIEW
const view = (model: Model): Document => {
const h = html<Message>()
return {
title: `Counter: ${model.count}`,
body: h.div(
[h.Class(containerStyle)],
[
h.div(
[h.Class('text-6xl font-bold text-gray-800')],
[model.count.toString()],
),
h.div(
[h.Class('flex flex-wrap justify-center gap-4')],
[
// OnClick takes a Message, not a callback. The Message doesn't
// execute anything. It just declares what should happen on click.
// Foldkit dispatches it to your update function.
h.button(
[h.OnClick(ClickedDecrement()), h.Class(buttonStyle)],
['-'],
),
h.button(
[h.OnClick(ClickedReset()), h.Class(buttonStyle)],
['Reset'],
),
h.button(
[h.OnClick(ClickedIncrement()), h.Class(buttonStyle)],
['+'],
),
],
),
],
),
}
}
// STYLE
const containerStyle =
'min-h-screen bg-cream flex flex-col items-center justify-center gap-6 p-6'
const buttonStyle = 'bg-black text-white hover:bg-gray-700 px-4 py-2 transition'No hook rules
In React, functional components can hold local state and run effects via hooks, which come with ordering rules you have to follow. In Foldkit, view is guaranteed pure: no hooks, no effects, no local state. It’s a function from Model to Html.
Foldkit’s HTML functions are typed to your Message type. This ensures event handlers only accept valid Messages from your application. Bind the factory once per module by calling html<Message>(), then reach for h.div, h.OnClick, and the rest off the returned record:
import { html } from 'foldkit/html'
// Bind the html factory to your Message type once at the top of each view
// function. Reach for `h.` to access elements, attributes, and event handlers.
// Every callback is typed against your Message union, so `h.OnClick(...)` only
// accepts your variants.
const greeting = (name: string) => {
const h = html<Message>()
return h.div(
[h.Class('flex flex-col gap-2')],
[
h.h1([h.Class('text-2xl font-bold')], [`Hello, ${name}`]),
h.button([h.OnClick(ClickedRefresh())], ['Refresh']),
],
)
}This gives you strong type safety: if you try to pass an invalid Message to h.OnClick, TypeScript catches it at compile time. Each view module binds its own h against the Message type it dispatches.
In a child view that should be agnostic to its parent, take ParentMessage as a function generic and bind html<ParentMessage>() inside. The view stays decoupled from any particular parent and composes through the toParentMessage callback the parent supplies.
When the customer flags the waiter, that’s a Message. In the view, event handlers work the same way. Instead of imperative callbacks that modify state, you pass a Message, or a function that maps an event to a Message.
// Event handlers take Messages, not callbacks.
// When the button is clicked, Foldkit dispatches the Message
// to your update function.
const buttonExample = () => {
const h = html<Message>()
return h.button(
[h.OnClick(ClickedIncrement()), h.Class('button-primary')],
['Click me'],
)
}
// For input events, Foldkit extracts the value and passes it
// to your function:
const inputExample = (model: Model) => {
const h = html<Message>()
return h.input([
h.OnInput(value => ChangedSearch({ text: value })),
h.Value(model.searchText),
h.Class('input'),
])
}For simple events like clicks, you pass the Message directly. For events that carry data (like input changes), you pass a function that receives the event and returns a Message. This keeps your view declarative. It describes what Messages should be sent, not how to handle them.
Foldkit runs your side effects for you. Your view only declares attributes and returns Messages. Usually Foldkit defers those effects to lifecycle primitives like Commands, Subscriptions, and Mounts, which run after the current event has returned. A few effects cannot wait that long. The browser only honors them when they run synchronously, inside the originating user-gesture event handler, and a deferred primitive runs a frame too late. Foldkit handles those from inside the event attribute itself. It is still Foldkit running the effect, not your view.
Two cases show up in practice. event.preventDefault() must run synchronously to suppress a default browser action like form submission or scroll. .focus() on iOS Safari only opens the on-screen keyboard if it runs inside the gesture; the same call from a Command resolves a frame later and the keyboard never appears.
Foldkit exposes these as attribute primitives. OnKeyDownPreventDefault takes a function returning Option<Message>. When the function returns Some, the framework calls preventDefault and dispatches the Message. OnClickFocus takes a selector and a Message; it synchronously focuses the element matching the selector and then dispatches.
const h = html<Message>()
// OnKeyDownPreventDefault: calls event.preventDefault()
// inline and dispatches the Message when the function
// returns Some.
h.input([
h.Value(model.draft),
h.OnKeyDownPreventDefault(key =>
key === 'Enter' && model.draft !== ''
? Option.some(SubmittedDraft())
: Option.none(),
),
])
// OnClickFocus: synchronously focuses the element matching
// the selector, then dispatches the Message. The focus runs
// inside the click event, so iOS Safari opens the on-screen
// keyboard. The target here is an always-present warmup input;
// a Dom.focus Command hands focus to the real search input
// once the dialog mounts.
h.button(
[
h.AriaLabel('Search documentation'),
h.OnClickFocus('#search-keyboard-warmup', ClickedSearch()),
],
[Icon.magnifyingGlass()],
)The iOS keyboard case has one wrinkle. The element you focus has to be in the page at the instant of the tap. A search field inside a dialog is not: while the dialog is closed its input is not rendered, and opening the dialog does not help because that happens a frame later, after the gesture has ended.
Focus a stand-in, then hand off
Keep an always-present, visually hidden text input (the “keyboard warmup”) and point OnClickFocus at it. The tap focuses the warmup (which opens the keyboard) and dispatches a Message. update’s branch for that Message opens the dialog and returns a Dom.focus Command pointed at the real input. It runs once the dialog has mounted, so focus lands on the real input, and iOS keeps the keyboard up as focus moves between the two text inputs.
These are ordinary declarative attributes, not an escape hatch into imperative code. Foldkit still owns the side effect and runs it inside the framework’s handler, so your callbacks stay pure and your Messages stay facts. Reach for them only when the browser requires a synchronous side effect inside the gesture. Anything that can wait belongs in the normal lifecycle, usually a Command.
So far everything has been synchronous. The user clicks a button, update produces a new Model, the view rerenders. But real apps need side effects: HTTP requests, timers, browser APIs. That’s where Commands come in.