Skip to main content
On this pageOverview

Commands

Overview

A Command is a description of a side effect — an HTTP request, a one-shot delay, a DOM focus call. The update function doesn’t actually do anything on its own: it returns data, and the Foldkit runtime reads the Commands and carries them out.

In the restaurant analogy, Commands are the slips the waiter hands to the kitchen. The waiter doesn’t cook — they describe what’s needed and hand it off. The kitchen does the work and reports back when it’s done.

When update runs, no HTTP request fires, no timer starts, no DOM changes. It returns a new Model and a list of Commands that describe what should happen, and the runtime executes them.

A different model for side effects

In React, event handlers do things directly — call fetch(), start a timer, write to localStorage. In Foldkit, update is pure. It describes what should happen and the runtime does it.

So far, update has been returning an empty Commands array. Let’s put it to use. Say we want a delayed reset: when the user clicks reset, the count resets after one second:

import { Effect, Match as M } from 'effect'
import { Command, Task } from 'foldkit'
import { m } from 'foldkit/message'

const ClickedResetAfterDelay = m('ClickedResetAfterDelay')
const CompletedDelayReset = m('CompletedDelayReset')

const DelayReset = Command.define('DelayReset', CompletedDelayReset)
const delayReset = DelayReset(
  Task.delay('1 second').pipe(Effect.as(CompletedDelayReset())),
)

const update = (
  model: Model,
  message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
  M.value(message).pipe(
    M.withReturnType<
      readonly [Model, ReadonlyArray<Command.Command<Message>>]
    >(),
    M.tagsExhaustive({
      ClickedResetAfterDelay: () => [model, [delayReset]],
      CompletedDelayReset: () => [{ count: 0 }, []],
    }),
  )

Anatomy of a Command

Look at what update does when ClickedResetAfterDelay arrives: it returns the Model unchanged, along with delayReset — a Command that describes a one-second delay. The update function didn’t start a timer. It handed the runtime a description that says “wait one second, then send me CompletedDelayReset.” The runtime does the waiting. When the delay fires, CompletedDelayReset arrives as a new Message, and update resets the count to zero.

A Command is a struct with two fields: name, identifying what the Command does, and effect, the Effect that the runtime executes. You create a Command in two steps: first, define its identity with Command.define, then call it with an Effect to produce the Command value.

This is the same idea as Messages. Just as m() gives a Message a name that the type system knows, Command.define gives a Command an identity that DevTools can display, tests can reference, and traces can track. The name isn’t a debug string — it’s a first-class value.

Names are verb-first imperatives: FetchWeather, FocusButton, LockScroll. Messages describe what happened (past tense), Command names are imperatives — instructions to the runtime.

Testable by Design

Commands aren’t just a fancy way to organize side effects. They’re the reason Foldkit programs are easy to test. Because update is pure and Commands are data, you can simulate the entire update loop without running any Effects. Send a Message, check that the right Command was produced, resolve it with a result, and verify the Model.

import { Story } from 'foldkit'
import { expect, test } from 'vitest'

test('delayed reset: count resets after the delay fires', () => {
  Story.story(
    update,
    Story.with({ count: 5 }),
    Story.message(ClickedResetAfterDelay()),
    Story.expectExactCommands(DelayReset),
    Story.resolve(DelayReset, CompletedDelayReset()),
    Story.model(model => {
      expect(model.count).toBe(0)
    }),
  )
})

The test reads as a story: start from a Model with count 5, send ClickedResetAfterDelay(), verify that update returned a DelayReset Command, resolve it with CompletedDelayReset(), and verify the count is 0. Every step is visible. The simulation called update, resolved the Command with the Message you provided, fed that back through update, and arrived at the final state.

Send Messages with Story.message, resolve Commands inline with Story.resolve, and assert with Story.model. See the Testing guide for the full API.

HTTP Requests

Now, what if we want to get the next count from an API instead of incrementing locally? We can create a Command that performs the HTTP request and returns a Message when it completes:

import { Effect, Match as M, Schema as S } from 'effect'
import { Command } from 'foldkit'
import { m } from 'foldkit/message'

const ClickedFetchCount = m('ClickedFetchCount')
const SucceededFetchCount = m('SucceededFetchCount', {
  count: S.Number,
})
const FailedFetchCount = m('FailedFetchCount', {
  error: S.String,
})

const FetchCount = Command.define(
  'FetchCount',
  SucceededFetchCount,
  FailedFetchCount,
)

const update = (
  model: Model,
  message: Message,
): readonly [Model, ReadonlyArray<Command.Command<Message>>] =>
  M.value(message).pipe(
    M.withReturnType<
      readonly [Model, ReadonlyArray<Command.Command<Message>>]
    >(),
    M.tagsExhaustive({
      ClickedFetchCount: () => [model, [fetchCount]],
      SucceededFetchCount: ({ count }) => [{ count }, []],
      FailedFetchCount: () => [model, []],
    }),
  )

const fetchCount = FetchCount(
  Effect.gen(function* () {
    const result = yield* Effect.tryPromise(() =>
      fetch('/api/count').then(res => {
        if (!res.ok) throw new Error('API request failed')
        return res.json() as unknown as { count: number }
      }),
    )
    return SucceededFetchCount({ count: result.count })
  }).pipe(
    Effect.catchAll(error =>
      Effect.succeed(FailedFetchCount({ error: error.message })),
    ),
  ),
)

Using fetch for simplicity

This example wraps fetch in Effect.tryPromise to keep the focus on Commands. For real applications, we recommend Effect’s HttpClient module — it gives you typed errors, request and response schemas, retries, and Layer-based configuration. See the Weather example for a real HttpClient usage — fetchWeather builds requests with HttpClientRequest.get, runs them through client.execute, and decodes the response with S.decodeUnknown. Whatever Effect produces the value, the Command shape stays the same.

Let’s zoom in on fetchCount to understand what’s happening here:

import { Effect } from 'effect'
import { Command } from 'foldkit'

const FetchCount = Command.define(
  'FetchCount',
  SucceededFetchCount,
  FailedFetchCount,
)

const fetchCount = FetchCount(
  Effect.gen(function* () {
    const result = yield* Effect.tryPromise(() =>
      fetch('/api/count').then(res => {
        if (!res.ok) throw new Error('API request failed')
        return res.json() as unknown as { count: number }
      }),
    )

    return SucceededFetchCount({ count: result.count })
  }).pipe(
    Effect.catchAll(error =>
      Effect.succeed(FailedFetchCount({ error: error.message })),
    ),
  ),
)

Errors are tracked, not hidden

Commands use Effect’s typed error channel — if a Command can fail, the type signature tells you. Effect.catchAll turns failures into Messages like FailedFetchCount, and once all errors are handled, the type confirms it. The update function handles errors the same way it handles success: as facts about what happened.

Commands fire once and produce a single Message when they finish. But what about effects that run continuously, like a timer that ticks every second, a WebSocket that stays open, keyboard input? For ongoing streams, Foldkit has Subscriptions.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson