Skip to main content
On this pageModel In, HTML Out

View

Model In, HTML Out

The view function turns the Model into HTML. Given the same Model, it produces the same output. It does not modify state or run Effects.

Event attributes complete the loop. They produce Messages for the runtime to dispatch, and update decides what those Messages mean. The view remains a pure description of what the user should see and which facts an interaction can report.

In the restaurant analogy, view is the meal on the table. The Model records the current facts; view presents them.

import type { Document, HtmlBuilder } from 'foldkit/html'

// VIEW

const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  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

React functional components can hold local state and run effects through hooks, which introduces ordering rules. A Foldkit view has neither hooks nor local state. It is a function from Model to Html.

The Document

A makeApplication view returns a Document, not bare HTML. The Document contains the body to patch into the application container and the document-level state that should track the Model.

FieldTypeRequiredWhat the runtime does with it
titlestringYesWrites it to document.title, so the browser tab tracks the current page.
bodyHtmlYesPatches it into the application container.
langstringNoSyncs it to lang on <html>. Omit it and the current value stands.
dir'Ltr' | 'Rtl' | 'Auto'NoSyncs it to dir on <html>, lowercased. Omit it and the current value stands.
canonicalstringNoSyncs it to <link rel="canonical">, creating the tag if absent. Defaults to the current URL.
ogUrlstringNoSyncs it to <meta property="og:url">, creating the tag if absent. Defaults to canonical.

Every field is a function of the Model, just like body. There is no imperative setTitle or separate head-management API. Return the values you want, and the runtime makes the document match after each render.

A makeElement view returns Html directly. An embedded app does not own the page, so it cannot declare the title or document metadata. Everything outside this section applies to both kinds of view. See Runtime for when to use each one.

Language and Direction

lang and dir sync to the <html> element. Drive them from the Model when the application can switch languages at runtime, just as title tracks the current page.

import type { Document, HtmlBuilder, TextDirection } from 'foldkit/html'

// TRANSLATION

const translate = (locale: Locale, key: string): string => {
  // Your catalog lookup. Foldkit does not ship translation.
  return catalog[locale][key]
}

// VIEW

const languageTag: Readonly<Record<Locale, string>> = {
  English: 'en',
  Arabic: 'ar',
  Japanese: 'ja',
}

const textDirection: Readonly<Record<Locale, TextDirection>> = {
  English: 'Ltr',
  Arabic: 'Rtl',
  Japanese: 'Ltr',
}

const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: translate(model.locale, 'PageTitle'),
  lang: languageTag[model.locale],
  dir: textDirection[model.locale],
  body: h.div(
    [h.Class('mx-auto max-w-prose p-6')],
    [
      h.h1([], [translate(model.locale, 'PageTitle')]),
      localePicker(model.locale, h),
    ],
  ),
})

dir accepts 'Ltr', 'Rtl', or 'Auto'. The runtime writes the corresponding lowercase attribute value. Auto delegates to the browser's first-strong-character heuristic. If the Model stores direction rather than deriving it, use the TextDirection Schema exported by foldkit/html.

Neither field has a default. If view omits one, the runtime leaves the existing attribute alone. An application that never sets lang therefore keeps the value from index.html.

The runtime can only synchronize these fields after the first render. Served HTML still determines what a crawler sees on first paint. If language is known per request, stamp <html lang> into the HTML shell and let the runtime keep it current after startup. Use the Lang attribute on an individual element when only one passage differs from the page language.

Canonical and Share URLs

canonical and ogUrl keep <link rel="canonical"> and <meta property="og:url"> current as the route changes. If both are omitted, they resolve to the current URL. If only canonical is set, ogUrl uses the same value.

Set them explicitly when the address bar does not identify the page you want indexed or shared. For example: later pages in a paginated list may point to the first page as canonical.

On a server render, the default is the full request URL, including its query string. Set canonical explicitly when a query parameter is not part of the page's identity, such as a tracking parameter or session token. Otherwise, a crawler can treat each query variant as a separate canonical page.

Typed HTML Helpers

Every view receives h, an HtmlBuilder typed to the application's Message union. Elements, attributes, and handlers all come from this builder:

import type { HtmlBuilder } from 'foldkit/html'

// Every view receives `h`, the typed Html builder, as its last argument.
// 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, h: HtmlBuilder<Message>) =>
  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']),
    ],
  )

The Message type follows the builder. If h.OnClick receives a Message outside the application union, TypeScript rejects it. The root runtime supplies its builder, and Submodel.defineView supplies one for each child view.

Element builders that accept children take attributes first and optional children second. Omit the children argument when there are no children: h.div([h.Class('divider')]). Attributes remain required, so h.div([]) represents an element with neither attributes nor children. h.keyed follows the element's rule, with the key before the attributes and children. Void elements such as h.img and h.br accept attributes only. A textarea is not void, but h.textarea and h.keyed('textarea') also accept attributes only; set their live content with h.Value, never children or h.InnerHTML. The foldkit/no-empty-children-array lint rule catches a trailing [] that carries no information.

Application code cannot construct a builder. It only enters through a view parameter, which keeps its Message type aligned with the boundary that dispatches its handlers. Extracted view helpers should take h: HtmlBuilder<Message> as their last parameter. A helper that works under any parent can introduce a ParentMessage generic and accept h: HtmlBuilder<ParentMessage>.

At module scope, where no view builder exists, foldkit/html exports inertHtml. It is an HtmlBuilder<never>, so it can build elements and styling attributes but cannot express a Foldkit event handler. Its Attribute<never> values can be used with any Message type, which lets a library publish reusable, handler-free attribute bundles. Import it as ih to distinguish it from the live builder passed to view. Markup built with ih may still vary with runtime data; inert means that it cannot dispatch a Message. Inside a view, use the builder supplied to that view.

Inert HTML is inert to Foldkit dispatch, not to the browser. A raw DOM attribute can still trigger browser behavior. The crash view, for example, uses h.Attribute('onclick', 'location.reload()') because its HtmlBuilder<never> cannot dispatch into a stopped update loop.

Raw HTML, script sources, and script attributes need trusted content

Several inputs run whatever you pass them, in the browser and in server-rendered HTML alike, and Foldkit does not sanitize them:

  • h.InnerHTML and h.Srcdoc render their strings as raw HTML.

  • A property named innerHTML from CustomElement.define writes raw HTML when the client assigns it, even though server rendering does not serialize that property.

  • h.Attribute writes a raw DOM attribute, including URL and event-handler attributes, without the sanitization a typed builder may apply.

  • A raw onclick-style attribute runs its string as script.

  • Text or h.InnerHTML inside a script or style element is emitted as trusted raw-text content. Script content executes, and style content can load resources and change the page.

  • The src of a <script> or <iframe>, and the data of an <object>, load and run whatever they point at, an http(s) or data: URL included.

Only ever pass content you control to these. A value built from user input, a URL parameter, or an API response is a cross-site scripting vector here, so build the markup from trusted inputs, or use h elements instead of raw HTML.

h.Href, h.Src, h.Action, and h.Formaction neutralize a javascript: or vbscript: URL (control-character obfuscation included) to an empty value, which stops the classic scheme-based injection on a link or form. That is a safety net, not a guarantee that any URL is safe: a <script> or <iframe> still runs an http(s) or data: source it is handed, so a URL that loads code must be trusted content like the sinks above.

Event Handling

Event attributes return Messages instead of mutating state. Pass a Message directly for a simple event, or use a function that translates event data into a Message:

// Event handlers take Messages, not callbacks.
// When the button is clicked, Foldkit dispatches the Message
// to your update function.
const buttonExample = (h: HtmlBuilder<Message>) =>
  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, h: HtmlBuilder<Message>) =>
  h.input([
    h.OnInput(value => ChangedSearch({ text: value })),
    h.Value(model.searchText),
    h.Class('input'),
  ])

h.OnClick takes a Message. h.OnInput takes a function because the input value becomes part of the Message. Both remain declarative: view reports what happened, and update decides what follows.

Clicks allow the browser default and bubble to ancestors unless you say otherwise. Pass a second argument when the view owns either part of that browser contract. For example, an expand button inside a clickable tree row can use { defaultAction: 'Prevent', propagation: 'Stop' }. The button dispatches its expansion Message without also dispatching the row's selection Message. The controls are independent, so use either one or both.

Complex Handlers

A handler can do more than return a one-line Message. The constraint is purity, not size. It may branch on event data, read Model-derived state from view scope, and return Option<Message> when only some events should dispatch:

import { Match, Option } from 'effect'
import type { HtmlBuilder } from 'foldkit/html'

// A handler is a pure translator from event data to a Message.
// It can branch as much as it needs to, with Model-derived state
// in scope.
const searchResultsView = (model: Model, h: HtmlBuilder<Message>) => {
  const handleResultsKeyDown = (key: string): Option.Option<Message> =>
    Match.value(key).pipe(
      Match.when('Escape', () => Option.some(DismissedResults())),
      Match.when('Enter', () =>
        Option.map(model.maybeActiveIndex, index => SelectedResult({ index })),
      ),
      Match.when('ArrowDown', () => Option.some(ActivatedNextResult())),
      Match.when('ArrowUp', () => Option.some(ActivatedPreviousResult())),
      // Every other key stays with the browser: no Message,
      // no preventDefault.
      Match.orElse(() => Option.none()),
    )

  return h.ul(
    [
      h.Role('listbox'),
      h.Tabindex(0),
      h.OnKeyDownPreventDefault(handleResultsKeyDown),
    ],
    model.results.map(result => resultView(result, h)),
  )
}

For OnKeyDownPreventDefault, returning Some claims the key. Foldkit suppresses the browser's default action and dispatches the Message. Returning None leaves the key to the browser.

Handlers never run Effects or decide consequences. The example classifies Enter with an active result as SelectedResult; update decides what selection changes. When a translator grows, extract it to a named pure function and pass that function to the attribute.

Focus Regions

Use OnFocusEnter and OnFocusLeave when several elements share one focus state. Put both attributes on their common ancestor. Foldkit dispatches when focus crosses that ancestor's boundary and ignores moves between its descendants.

Imagine a text editor and its formatting toolbar. Focusing the editor dispatches EnteredEditorRegion. Moving from the editor into a toolbar button dispatches nothing, so the toolbar stays visible. Moving from either one to an element outside the region dispatches LeftEditorRegion.

import type { HtmlBuilder } from 'foldkit/html'

const editorView = (model: Model, h: HtmlBuilder<Message>) =>
  h.div(
    [
      h.OnFocusEnter(Message.EnteredEditorRegion()),
      h.OnFocusLeave(Message.LeftEditorRegion()),
    ],
    [
      tiptapEditorView(model.editor, h),
      model.focusState === 'Within' ? formattingToolbarView(h) : h.empty,
    ],
  )

The attributes use the bubbling focusin and focusout events, so the common ancestor does not need a tabindex. Foldkit reads the related target and performs the containment check inside its own event handler. View code receives no DOM event or element.

These attributes report transitions only after their listeners attach. Hydration preserves focus on an adopted element, but Foldkit does not invent an OnFocusEnter Message for an earlier event it did not observe. Use :focus-within when focus only affects presentation. If Model state must reflect focus that may already exist during hydration, reconcile document.activeElement from a Mount after the element exists.

Event Handler Side Effects

Most side effects can run after the browser event returns, through a Command, Subscription, or Mount. A few browser APIs only work synchronously inside the originating user gesture. Foldkit exposes dedicated attributes for those cases, so the framework still owns the effect and the view callback stays pure.

Two constraints account for most uses. event.preventDefault() must run before the browser commits its default action. On iOS Safari, .focus() must run during the gesture to open the on-screen keyboard.

OnClick accepts defaultAction, propagation, and focusSelector controls. Foldkit applies them synchronously before dispatching the Message. OnKeyDownPreventDefault lets a translator decide whether to claim a key event. OnPastePreventDefault passes the clipboard's text/plain payload to its translator; Some suppresses the default insertion and dispatches the Message, while None leaves the paste alone. OnCopyText and OnCutText write Model-derived text to the clipboard and suppress the browser's default payload; the cut variant also dispatches a Message.

// Inside a view, with its builder `h` in scope.

// 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(Message.SubmittedDraft())
      : Option.none(),
  ),
])

// OnPastePreventDefault: reads the clipboard's text/plain
// payload synchronously inside the paste event. Some
// suppresses the browser's default insertion and dispatches
// the Message; None lets the browser paste normally.
//
// OnCopyText and OnCutText write Model-derived text to the
// clipboard inside the gesture and suppress the default
// payload. The cut variant also dispatches a Message so
// update can delete the selection.
h.div([
  h.Contenteditable('true'),
  h.OnPastePreventDefault(text => Option.some(Message.PastedText({ text }))),
  h.OnCopyText(serializeSelectionToMarkdown(model)),
  h.OnCutText(serializeSelectionToMarkdown(model), Message.CutSelection()),
])

// OnClick controls can be combined. This nested expand button prevents
// its browser default and stops the row's OnClick from also
// selecting the row.
h.div(
  [h.Role('treeitem'), h.OnClick(Message.ClickedSelectRow())],
  [
    h.button(
      [
        h.OnClick(Message.ClickedExpandRow(), {
          defaultAction: 'Prevent',
          propagation: 'Stop',
        }),
      ],
      ['Expand'],
    ),
  ],
)

// focusSelector synchronously focuses the matching element,
// 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.OnClick(Message.ClickedSearch(), {
      focusSelector: '#search-keyboard-warmup',
    }),
  ],
  [Icon.magnifyingGlass()],
)

The iOS keyboard case has one extra constraint: the target must already exist when the user taps. An input inside a closed dialog does not. Keep an always-present, visually hidden text input as a keyboard warmup and pass its selector as focusSelector to OnClick. The same attribute dispatches the Message that opens the dialog. Update can then return a Dom.focus Command to move focus to the real input after it mounts, while iOS keeps the keyboard open.

These controls and attributes are narrow browser-integration primitives, not a general escape hatch. Use them only when the browser requires synchronous work inside a gesture. Anything that can wait belongs in the normal lifecycle, usually a Command.

The basic loop is now complete: a Message reaches update, update returns a Model, and view renders it. The next step is side effects. Commands describe one-shot work for the runtime to execute.