Skip to main content
On this pageOverview

Side Effects & Purity

Overview

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 calling event.preventDefault()), use Stream.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: acquire and release Effects 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.

Why Zero Side Effects?

Foldkit gains powerful guarantees from zero side effects:

  • DevTools replay: the DevTools can replay any sequence of Messages against your update function because it’s pure. If update had 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 update tells 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.

Common Mistakes

  • console.log in update: console.log during 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() in update: calling Date.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.
  • fetch in view: the view is called on every render. Instead, return a Command from update that fetches your data and returns a Message. Handle the Message to update your Model.
  • DOM access anywhere: reading document.getElementById or window.innerWidth breaks purity. Use Subscriptions for reactive values, or Commands for one-off reads.

Pure Functions Everywhere

View is Pure

  • 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())], ['+']),
    ],
  ),
})

Update is Pure

  • 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.

Requesting Values

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.

Don’t Compute in Update

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 }

Request Via Command

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.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson