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 } from 'foldkit'
import { m } from 'foldkit/message'

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

const DelayReset = Command.define(
  // The identifier for the Command, surfaces in DevTools and Story/Scene tests
  'DelayReset',
  // The returned Message (can be more than one)
  CompletedDelayReset,
)(
  // The Effect
  Effect.sleep('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 three fields: name, identifying what the Command does; args, the typed input record (when declared); and effect, the Effect the runtime executes. You create one in two curried steps: first, declare the identity and shape with Command.define; then call the result with an Effect (or with a builder that receives the typed args, when args are declared) 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 a name and shape that DevTools can display, tests can reference, and traces can track. The name and args aren’t debug strings. They’re first-class values.

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

Args carry the inputs that vary per dispatch. Anything else the Effect needs comes in through the Effect itself: module-level constants live in lexical scope, app-wide dependencies arrive through Foldkit Resources, model-driven handles arrive through ManagedResources, and any service tag on the Effect’s context channel is pulled with yield*. Args don’t have to carry every value the Effect uses; they carry the per-dispatch inputs.

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.Command.expectExact(DelayReset),
    Story.Command.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.Command.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 {
  FetchHttpClient,
  HttpClient,
  HttpClientRequest,
} from 'effect/unstable/http'
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 CountResponse = S.Struct({ count: S.Number })

const FetchCount = Command.define(
  'FetchCount',
  SucceededFetchCount,
  FailedFetchCount,
)(
  Effect.gen(function* () {
    const client = yield* HttpClient.HttpClient
    const response = yield* client.execute(HttpClientRequest.get('/api/count'))

    if (response.status !== 200) {
      return yield* Effect.fail('API request failed')
    }

    const { count } = yield* S.decodeUnknownEffect(CountResponse)(
      yield* response.json,
    )
    return SucceededFetchCount({ count })
  }).pipe(
    Effect.catch(error =>
      Effect.succeed(FailedFetchCount({ error: String(error) })),
    ),
    Effect.provide(FetchHttpClient.layer),
  ),
)

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, []],
    }),
  )

Let’s zoom in on FetchCount to see how an HTTP-backed Command takes shape. The Effect pulls HttpClient from the context, executes a typed request, decodes the JSON response with Schema, and produces SucceededFetchCount. Failures get caught and turned into FailedFetchCount Messages, so the runtime always sees a result. Effect.provide(FetchHttpClient.layer) wires the live implementation; tests can swap it for a mock.

Errors are tracked, not hidden

Commands use Effect’s typed error channel: if a Command can fail, the type signature tells you. Effect.catch 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 with Args

The Commands so far have taken no inputs. But many Commands need values that vary per dispatch: the zip code for a weather lookup, the element id for a focus call, the duration for a delay. Declare those values as an args schema between the Command name and the result Messages. The factory then receives them as a typed record, and call sites pass them in when dispatching.

import { Effect, Match as M, Schema as S } from 'effect'
import {
  FetchHttpClient,
  HttpClient,
  HttpClientRequest,
} from 'effect/unstable/http'
import { Command } from 'foldkit'
import { m } from 'foldkit/message'

const SubmittedWeatherForm = m('SubmittedWeatherForm')
const SucceededFetchWeather = m('SucceededFetchWeather', {
  weather: WeatherSchema,
})
const FailedFetchWeather = m('FailedFetchWeather', { error: S.String })

const FetchWeather = Command.define(
  'FetchWeather',
  // Args schema: the per-dispatch inputs the factory needs.
  { zipCode: S.String },
  SucceededFetchWeather,
  FailedFetchWeather,
)(
  // The factory receives a typed args record.
  ({ zipCode }) =>
    Effect.gen(function* () {
      const client = yield* HttpClient.HttpClient
      const response = yield* client.execute(
        HttpClientRequest.get(`/api/weather?zip=${zipCode}`),
      )
      const weather = yield* S.decodeUnknownEffect(WeatherSchema)(
        yield* response.json,
      )
      return SucceededFetchWeather({ weather })
    }).pipe(
      Effect.catch(error =>
        Effect.succeed(FailedFetchWeather({ error: String(error) })),
      ),
      Effect.provide(FetchHttpClient.layer),
    ),
)

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({
      // Pass args when dispatching the Command.
      SubmittedWeatherForm: () => [
        model,
        [FetchWeather({ zipCode: model.zipCodeInput })],
      ],
      SucceededFetchWeather: ({ weather }) => [{ ...model, weather }, []],
      FailedFetchWeather: () => [model, []],
    }),
  )

Args appear in DevTools alongside the Command name and let Story/Scene tests assert on the exact dispatch with Scene.Command.expectExact(FetchWeather({ zipCode: '90210' })).

Commands fire once and produce one result Message when they finish (chosen from the result Messages they declare). For work bound to a specific DOM element’s lifetime, Foldkit has Mount.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson