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 of Commands. Subscriptions model ongoing processes like keyboard events, window resizing, or intersection observers. Side effects in streams are wrapped in Effect primitives like
Stream.asyncandStream.tap— 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 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 { Html, html } from 'foldkit/html'
import { Model } from './model'
const { div } = html()
// ❌ Don't do this in view
const view = (model: Model): Html => {
// Fetching data in view
fetch('/api/user').then(res => res.json())
// Setting timers
setTimeout(() => console.log('tick'), 1000)
// Subscriptions
window.addEventListener('resize', handleResize)
return div([], ['Hello'])
}import { Html, html } from 'foldkit/html'
import { ClickedIncrement, Message } from './message'
import { Model } from './model'
const { button, div, h1, p, Class, OnClick } = html<Message>()
// ✅ View is just a pure function from Model to Html
const view = (model: Model): Html =>
div(
[Class('container')],
[
h1([], [model.title]),
p([], [`Count: ${model.count}`]),
button([OnClick(ClickedIncrement())], ['+']),
],
)- Returns a new Model and a list of Commands — doesn’t execute anything. 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
],
SucceededUserFetch: ({ user }) => [
evo(model, { isLoading: () => false, user: () => user }),
[], // Result received, no more commands needed
],
}),
)Foldkit’s pure update model makes testing painless because state transitions are just function calls — pass in a Model and Message, assert on the returned Model. And because Commands are Effects with explicit dependencies, you can swap in mocks without reaching for libraries like msw or stubbing globals:
import { HttpClient, HttpClientResponse } from '@effect/platform'
import { Effect, Layer, Match as M, String } from 'effect'
import { expect, test } from 'vitest'
import {
Model,
SubmittedWeatherForm,
WeatherInit,
fetchWeather,
update,
} from './main'
const createModel = (): Model => ({
zipCodeInput: '90210',
weather: WeatherInit(),
})
test('SubmittedWeatherForm sets loading state and returns fetch Command', () => {
const model = createModel()
const [newModel, commands] = update(model, SubmittedWeatherForm())
expect(newModel.weather._tag).toBe('WeatherLoading')
expect(commands).toHaveLength(1)
})
test('fetchWeather returns SucceededWeatherFetch with data on success', async () => {
const mockClient = HttpClient.make(request =>
Effect.sync(() => {
const responseData = M.value(request.url).pipe(
M.when(String.includes('geocoding'), () => ({
results: [
{
name: 'Beverly Hills',
latitude: 34.07362,
longitude: -118.40036,
admin1: 'California',
},
],
})),
M.when(String.includes('forecast'), () => ({
current: {
time: '2026-03-10T01:30',
interval: 900,
temperature_2m: 72.4,
relative_humidity_2m: 45,
wind_speed_10m: 9.8,
weather_code: 0,
},
})),
M.orElse(url => {
throw new Error(`Unexpected request URL: ${url}`)
}),
)
return HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify(responseData), { status: 200 }),
)
}),
)
const message = await fetchWeather('90210').pipe(
Effect.provide(Layer.succeed(HttpClient.HttpClient, mockClient)),
Effect.runPromise,
)
expect(message._tag).toBe('SucceededWeatherFetch')
})See the Weather example tests for a complete implementation.
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/command'
import { GRID_SIZE } from './constants'
import { GeneratedApple, Message, RequestedApple } from './message'
import { Model } from './model'
// ✅ Do this - request the value via a Command
const update = (model: Model, message: Message) =>
Match.value(message).pipe(
Match.tagsExhaustive({
RequestedApple: () => [model, [generateApplePosition]],
GeneratedApple: ({ position }) => [{ ...model, apple: position }, []],
}),
)
// The Command that performs the side effect
const generateApplePosition: Command<Message> = Effect.gen(function* () {
const x = yield* Random.nextIntBetween(0, GRID_SIZE)
const y = yield* Random.nextIntBetween(0, GRID_SIZE)
return GeneratedApple({ position: { x, y } })
})
// Same inputs always produce the same outputs - purity preserved!
const model = { snake: [{ x: 0, y: 0 }], apple: { x: 5, y: 5 } }
const message = RequestedApple()
console.log(update(model, message)) // [model, [generateApplePosition]]
console.log(update(model, message)) // [model, [generateApplePosition]]
console.log(update(model, message)) // [model, [generateApplePosition]]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.