On this pagePurity in Foldkit
Side Effects and Purity
Foldkit keeps view and update pure. They describe the next UI and any work to perform, but they do not perform that work themselves.
view returns a Document or Html value. Its event handlers construct Messages. update returns the next Model, Commands, and, for a Submodel, an optional OutMessage. Given the same inputs, both functions make the same decisions without reading or changing the outside world.
Effectful work lives at boundaries managed by the Runtime. Depending on the boundary, that work is described by an Effect, Stream, or Layer:
Commands describe one-shot work caused by a Message, such as an HTTP request, navigation, storage operation, or focus change.
Mount describes work tied to one live
Element. Use it for element measurement, observers, portaling, and imperative third-party libraries.Flags obtain the outside data needed before init can construct the first Model.
Subscriptions describe ongoing work whose lifetime follows dependencies derived from the Model.
Resources provide app-lifetime services shared by Commands, Subscriptions, Mounts, and Flags.
ManagedResource acquires a typed stateful handle while a Model condition holds. Commands and Subscriptions can use that handle while it is live.
These descriptions do nothing until the Runtime starts them. One narrow exception stays inside its boundary: a DOM-event mapper may need to perform synchronous browser work before returning a Message. Use Subscription.fromEventFilterMapPreventDefault when handling an event should also cancel its default action. The helper runs its mapper and calls preventDefault() inside the browser's dispatch, while a downstream Stream operator runs too late to cancel the event.
A CustomElement binding remains declarative. Properties flow from the Model into the native element, and its events return as Messages. The browser owns the custom element's internal implementation.
Replay stays safe. DevTools can replay Messages through update without firing network requests, analytics, storage writes, or DOM work again.
State remains explainable. Each Model follows from the previous Model and one Message. The history does not depend on a hidden callback changing data elsewhere.
Tests stay deterministic. Story tests resolve Command results explicitly, while Scene tests acknowledge effect boundaries surfaced by the rendered view.
For example:
Production logging inside update runs again during DevTools replay. Put logging and error reporting in a Command. Temporary
console.logcalls are still useful while debugging, but remove them when the investigation ends.Date.now()andMath.random()inside update make the result depend on when it runs. Ask for time or randomness through a Command and return the value in its result Message.fetchinside view starts work whenever the view renders. Start the request with a Command returned by update.Reading
documentorwindowinside view or update hides browser state outside the Model. Use a Command for one-shot reads, a Subscription for ongoing external state, or Mount when the work requires a particular live element.
View reads the Model and any declared ViewInputs, then returns Document or Html. It does not fetch, schedule timers, subscribe, or read live DOM state. Event attributes construct Messages for the Runtime to dispatch.
import type { Document, HtmlBuilder } from 'foldkit/html'
import type { Message } from './message'
import type { Model } from './model'
// ❌ Don't do this in view
const view = (model: Model, h: HtmlBuilder<Message>): Document => {
fetch('/api/user').then(res => res.json())
setTimeout(() => console.log('tick'), 1000)
window.addEventListener('resize', () => {})
return { title: model.title, body: h.div([], [model.title]) }
}import type { Document, HtmlBuilder } from 'foldkit/html'
import { ClickedIncrement, type Message } from './message'
import type { Model } from './model'
// ✅ Keep view pure
const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
title: model.title,
body: h.div(
[h.Class('container')],
[
h.h1([], [model.title]),
h.p([], [`Count: ${model.count}`]),
h.button([h.OnClick(ClickedIncrement())], ['+']),
],
),
})Update reads the current Model and one Message. It returns a new Model plus descriptions of any work that should follow. It does not mutate the Model, touch the DOM, or execute a Command.
import { type Update } from 'foldkit'
import { evo } from 'foldkit/struct'
import { Message } from './message'
import type { Model } from './model'
// ❌ Don't do this in update
const update = (model: Model, message: Message) =>
Message.match<Update.Return<Model, Message>>(message, {
OpenedDialog: () => {
document.querySelector<HTMLInputElement>('#search-input')?.focus()
return { model: evo(model, { dialogState: () => 'Open' }) }
},
})import { Effect } from 'effect'
import { Command, Dom, type Update } from 'foldkit'
import { evo } from 'foldkit/struct'
import { Message } from './message'
import type { Model } from './model'
const FocusSearchInput = Command.define('FocusSearchInput', {
messages: [Message.CompletedFocusSearchInput],
execute: Dom.focus('#search-input').pipe(
Effect.ignore,
Effect.as(Message.CompletedFocusSearchInput()),
),
})
// ✅ Return the next Model and a Command
const update = (model: Model, message: Message) =>
Message.match<Update.Return<Model, Message>>(message, {
OpenedDialog: () => ({
model: evo(model, { dialogState: () => 'Open' }),
commands: [FocusSearchInput()],
}),
CompletedFocusSearchInput: () => ({ model }),
})The Testing guide shows how Story drives update and resolves Commands without a DOM, while Scene exercises the effect boundaries exposed by a rendered view.
Randomness, clocks, storage, and browser APIs produce values that are not already in the Model or Message. Request those values through a Command.
This version generates a different position each time update receives the same inputs:
import { type Update } from 'foldkit'
import { evo } from 'foldkit/struct'
import { GRID_SIZE } from './constants'
import { Message } from './message'
import type { Model } from './model'
// ❌ Don't call random directly in update
const update = (model: Model, message: Message) =>
Message.match<Update.Return<Model, Message>>(message, {
RequestedApple: () => {
const x = Math.floor(Math.random() * GRID_SIZE)
const y = Math.floor(Math.random() * GRID_SIZE)
return { model: evo(model, { apple: () => ({ x, y }) }) }
},
})The pure version returns GenerateApplePosition. Its Effect generates the coordinates and sends them back in CompletedGenerateApplePosition:
import { Effect, Random } from 'effect'
import { Command, type Update } from 'foldkit'
import { evo } from 'foldkit/struct'
import { GRID_SIZE } from './constants'
import { Message } from './message'
import type { Model } from './model'
// ✅ Run random work in a Command
const GenerateApplePosition = Command.define('GenerateApplePosition', {
messages: [Message.CompletedGenerateApplePosition],
execute: Effect.gen(function* () {
const x = yield* Random.nextIntBetween(0, GRID_SIZE, { halfOpen: true })
const y = yield* Random.nextIntBetween(0, GRID_SIZE, { halfOpen: true })
return Message.CompletedGenerateApplePosition({ position: { x, y } })
}),
})
const update = (model: Model, message: Message) =>
Message.match<Update.Return<Model, Message>>(message, {
RequestedApple: () => ({ model, commands: [GenerateApplePosition()] }),
CompletedGenerateApplePosition: ({ position }) => ({
model: evo(model, { apple: () => position }),
}),
})RequestedApple now returns the same Model and Command every time. Only the result handler writes the generated position into the Model.
See the Snake example for a complete implementation of this pattern.