On this pagePure Functions Everywhere
Best Practices
Foldkit requires a different way of thinking than most TypeScript frameworks. These patterns will help you write maintainable applications.
In Foldkit, both view and update are pure functions. They take inputs and return outputs without side effects.
- 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
],
}),
)Side effects happen in Commands. A Command is an Effect that describes a side effect — fetch this URL, wait 500ms, read from storage. Your update function doesn't execute anything; it just returns data describing what should happen. Foldkit's runtime takes those Commands, executes them, and feeds the results back as Messages.
This means side effects still happen — you're not avoiding them. But they happen in a contained environment managed by the runtime, not scattered throughout your code. Your business logic stays pure: given the same inputs, it always returns the same outputs. The impurity is pushed to the edges.
Unlike React where side effects can trigger during render (useEffect), Foldkit side effects only happen in response to Messages. This separation makes your code predictable and testable.
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 } from 'effect'
import { expect, test } from 'vitest'
import { ClickedFetchWeather, fetchWeather, update } from './main'
test('ClickedFetchWeather sets loading state and returns fetch command', () => {
const model = createModel()
const [newModel, commands] = update(model, ClickedFetchWeather())
expect(newModel.weather._tag).toBe('WeatherLoading')
expect(commands).toHaveLength(1)
})
test('fetchWeather returns SucceededWeatherFetch with data on success', async () => {
const mockResponse = {
current_condition: [
{ temp_F: '72', weatherDesc: [{ value: 'Sunny' }] },
],
nearest_area: [{ areaName: [{ value: 'Beverly Hills' }] }],
}
// Provide a mock HttpClient - no msw or fetch mocking needed
const mockClient = HttpClient.make(req =>
Effect.succeed(
HttpClientResponse.fromWeb(
req,
new Response(JSON.stringify(mockResponse), { 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, RequestedAppleSpawn } 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({
RequestedAppleSpawn: () => {
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 = RequestedAppleSpawn()
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 {
GotApplePosition,
Message,
RequestedAppleSpawn,
} 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({
RequestedAppleSpawn: () => [model, [generateApplePosition]],
GotApplePosition: ({ 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 GotApplePosition({ 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 = RequestedAppleSpawn()
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 RequestedAppleSpawn 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 GotApplePosition.
See the Snake example for a complete implementation of this pattern.
Foldkit provides evo for immutable model updates. It wraps Effect's Struct.evolve with stricter type checking — if you remove or rename a key from your Model, you'll get type errors everywhere you try to update it.
import { evo } from 'foldkit/struct'
type Model = { count: number; lastUpdated: number }
const model: Model = { count: 0, lastUpdated: 0 }
// evo takes the model and an object of transform functions
const newModel = evo(model, {
count: count => count + 1,
lastUpdated: () => Date.now(),
})
// Invalid keys are caught at compile time
const badModel = evo(model, {
counnt: count => count + 1, // ❌ Error: 'counnt' does not exist in Model
})Each property in the transform object is a function that takes the current value and returns the new value. Properties not included remain unchanged.
Messages describe what happened, not what to do. Name them as verb-first, past-tense events where the prefix acts as a category marker: Clicked* for button presses, Updated* for input changes, Requested* for async triggers, Got* for data responses. For example, ClickedFormSubmit and RemovedCartItem rather than imperative commands like SubmitForm or RemoveFromCart.
- ClickedAddToCart
- ChangedSearchInput
- ReceivedUserData
- SetCartItems
- UpdateSearchText
- MutateUserState
The update function decides how to handle a Message. The Message itself is just a fact about what occurred.