On this pageOverview
Side Effects & Purity
Correct Foldkit programs have zero side effects, period. Yes, zero (0).
Every side effect is described as an Effect: a value that represents a computation without executing it. An Effect does nothing when you construct it. It produces side effects when the Foldkit runtime runs your program.
Both view and update are pure functions. They take inputs and return outputs without touching the outside world.
You encapsulate side effects in exactly five places:
- Commands: an Effect that performs a side effect and returns a Message. HTTP requests, DOM operations, reading from storage. This is where most of your side effects live.
- flags: an Effect that returns the initial data your program needs to start. Reading from local storage, detecting browser capabilities, or fetching configuration.
- Subscription streams: a
Stream<Message>. Subscriptions model ongoing processes like keyboard events, window resizing, or intersection observers. When a stream callback needs to perform a side effect before producing a Message (like callingevent.preventDefault()), useStream.mapEffect. The runtime controls when streams subscribe and unsubscribe based on your Model. - Resources: an Effect Layer that provides long-lived services to your Commands. One-time setup like creating an AudioContext or opening a database connection.
- Managed Resources:
acquireandreleaseEffects for stateful resources that activate and deactivate based on your Model. Camera streams, WebSocket connections, media recorders.
That’s it. Every side effect in your program is an Effect value, managed by the runtime. Your logic is pure.
Foldkit gains powerful guarantees from zero side effects:
- DevTools replay: the DevTools can replay any sequence of Messages against your
updatefunction because it’s pure. Ifupdatehad side effects, replaying would double-fire them. - Time-travel debugging: you can jump to any point in your app’s history and see exactly what the Model looked like, because each state is a deterministic function of the previous state plus the Message.
- Predictability: reading
updatetells you everything about how a Message changes the Model. There are no hidden effects, no action-at-a-distance, no callbacks firing behind the scenes.
console.loginupdate:console.logduring development is fine for quick debugging. But production logging or error monitoring is a side effect that belongs in a Command. It will fire again during DevTools replay, and you want structured control over what gets reported.Date.now()inupdate: callingDate.now()breaks purity because the same Model and Message produce different results depending on when they run. Request the current time via a Command using Task.getTime, Task.getZonedTime, or Task.getZonedTimeIn and return it as a Message.fetchinview: the view is called on every render. Instead, return a Command fromupdatethat fetches your data and returns a Message. Handle the Message to update your Model.- DOM access anywhere: reading
document.getElementByIdorwindow.innerWidthbreaks purity. Use Subscriptions for reactive values, or Commands for one-off reads.
- No hooks, no lifecycle methods
- No fetching data, no timers, no subscriptions
- Given the same Model, always returns the same Html
import { Document, html } from 'foldkit/html'
import { Model } from './model'
const { div } = html()
// ❌ Don't do this in view
const view = (model: Model): Document => {
// Fetching data in view
fetch('/api/user').then(res => res.json())
// Setting timers
setTimeout(() => console.log('tick'), 1000)
// Subscriptions
window.addEventListener('resize', handleResize)
return { title: 'Hello', body: div([], ['Hello']) }
}import { Document, html } from 'foldkit/html'
import { ClickedIncrement, Message } from './message'
import { Model } from './model'
const { button, div, h1, p, Class, OnClick } = html<Message>()
// ✅ View is a pure function from Model to a Document describing the page
const view = (model: Model): Document => ({
title: model.title,
body: div(
[Class('container')],
[
h1([], [model.title]),
p([], [`Count: ${model.count}`]),
button([OnClick(ClickedIncrement())], ['+']),
],
),
})- Returns a new Model and a list of Commands. It doesn’t execute anything. Each Command carries a name for tracing and testing. Foldkit runs the provided Commands.
- No mutations, no side effects
- Given the same Model and Message, always returns the same result
import { Match } from 'effect'
import { Message } from './message'
import { Model } from './model'
// ❌ Don't do this in update
const update = (model: Model, message: Message) =>
Match.value(message).pipe(
Match.tagsExhaustive({
ClickedFetchUser: () => {
// Making HTTP requests directly
fetch('/api/user').then(res => {
model.user = res.json() // Mutating state!
})
return [model, []]
},
}),
)import { Match } from 'effect'
import { evo } from 'foldkit/struct'
import { fetchUser } from './command'
import { Message } from './message'
import { Model } from './model'
// ✅ Update returns new state and commands
const update = (model: Model, message: Message) =>
Match.value(message).pipe(
Match.tagsExhaustive({
ClickedFetchUser: () => [
evo(model, { isLoading: () => true }),
[fetchUser(model.userId)], // Command handles the side effect
],
SucceededFetchUser: ({ user }) => [
evo(model, { isLoading: () => false, user: () => user }),
[], // Result received, no more commands needed
],
}),
)This purity has a practical payoff: testing is trivial. Foldkit ships foldkit/test: a simulation module that lets you send Messages, declare Command resolvers, and assert on the Model in a single pipe chain. See the Testing guide for the full API.
A common mistake is computing random or time-based values directly in update. This breaks purity. Calling the function twice with the same inputs would return different results.
import { Match } from 'effect'
import { GRID_SIZE } from './constants'
import { Message, RequestedApple } from './message'
import { Model } from './model'
// ❌ Don't do this - calling random directly in update
const update = (model: Model, message: Message) =>
Match.value(message).pipe(
Match.tagsExhaustive({
RequestedApple: () => {
const x = Math.floor(Math.random() * GRID_SIZE)
const y = Math.floor(Math.random() * GRID_SIZE)
return [{ ...model, apple: { x, y } }, []]
},
}),
)
// Same inputs produce different outputs - this breaks purity!
const model = { snake: [{ x: 0, y: 0 }], apple: { x: 5, y: 5 } }
const message = RequestedApple()
console.log(update(model, message)[0].apple) // { x: 12, y: 7 }
console.log(update(model, message)[0].apple) // { x: 3, y: 19 }
console.log(update(model, message)[0].apple) // { x: 8, y: 2 }Instead, return a Command that generates the value and sends it back as a Message:
import { Effect, Match, Random } from 'effect'
import { Command } from 'foldkit'
import { GRID_SIZE } from './constants'
import { GeneratedApple, Message, RequestedApple } from './message'
import { Model } from './model'
const update = (model: Model, message: Message) =>
Match.value(message).pipe(
Match.tagsExhaustive({
RequestedApple: () => [model, [generateApplePosition]],
GeneratedApple: ({ position }) => [{ ...model, apple: position }, []],
}),
)
const GenerateApplePosition = Command.define(
'GenerateApplePosition',
GeneratedApple,
)
const generateApplePosition = GenerateApplePosition(
Effect.gen(function* () {
const x = yield* Random.nextIntBetween(0, GRID_SIZE)
const y = yield* Random.nextIntBetween(0, GRID_SIZE)
return GeneratedApple({ position: { x, y } })
}),
)
const model = { snake: [{ x: 0, y: 0 }], apple: { x: 5, y: 5 } }
const message = RequestedApple()
console.log(update(model, message))
console.log(update(model, message))
console.log(update(model, message))This “request/response” pattern keeps update pure. The RequestedApple handler always returns the same result. It just emits a Command. The actual random generation happens in the Effect, and the result comes back via GeneratedApple.
See the Snake example for a complete implementation of this pattern.