Skip to main content
Foldkit
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.

Pure Functions Everywhere

In Foldkit, both view and update are pure functions. They take inputs and return outputs without side effects.

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

Update is Pure

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

Testing Update Functions

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.

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, 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 }

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

Immutable Updates with evo

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 as Events

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.

Good Message Names

  • ClickedAddToCart
  • ChangedSearchInput
  • ReceivedUserData

Avoid These

  • SetCartItems
  • UpdateSearchText
  • MutateUserState

The update function decides how to handle a Message. The Message itself is just a fact about what occurred.