Skip to content

Latest commit

 

History

History
1288 lines (930 loc) · 41 KB

File metadata and controls

1288 lines (930 loc) · 41 KB

API

This page documents the core @tdreyno/fizz APIs intended for everyday machine authoring. It focuses on the root exports you use to define actions, states, effects, and runtimes.

Dedicated guides already cover the deeper scheduling and testing APIs:

Optional fluent authoring style:

Use the fluent entry point when you prefer chain-first state definitions:

import { state } from "@tdreyno/fizz/fluent"

Optional browser, debugging, and utility subpaths:

  • @tdreyno/fizz/browser: DOM/browser drivers
  • @tdreyno/fizz/debug: Runtime debugging utilities (tree-shakable)
  • @tdreyno/fizz/registry: Registry lifecycle APIs (tree-shakable)
  • @tdreyno/fizz/nested: Nested machine helpers
  • @tdreyno/fizz/parallel: Parallel machine API
  • @tdreyno/fizz/test: Testing utilities

The root @tdreyno/fizz object-style APIs remain fully supported.

Machines

createMachine

Create an explicit machine root that groups your top-level states, actions, and optional output actions in one value. Pass an optional second name argument when you want a stable machine name for CLI discovery, logging, or debugging.

const EditorMachine = createMachine(
  {
    actions: { saveDraft, startEditing },
    initialState: Idle({ draftId: null }),
    outputActions: { draftSaved },
    states: { Editing, Idle },
  },
  "EditorMachine",
)

Use createMachine(...) when you want one stable root for integrations, examples, or CLI discovery. Set initialState when the machine should carry a default starting state for helpers like createParallelMachine(...). The CLI only discovers default-exported machine roots created this way.

Output map aliases and command-channel output ergonomics are documented in Output Actions.

Each created machine also exposes .withInitialState(...) to produce a copy with a different runtime starting state.

const RuntimeEditorMachine = EditorMachine.withInitialState(
  Editing({ draftId: "draft-42" }),
)

selectWhen

Define colocated selectors on a machine root for read-only derived checks. Selectors are part of the core machine definition and are not React-specific.

const EditorMachine = createMachine({
  actions: { startEditing },
  selectors: {
    isEditable: selectWhen(Editing, data => !data.readOnly),
    canReview: selectWhen([Editing, Reviewing] as const, (data, state) => {
      if (state.is(Editing)) {
        return !data.readOnly
      }

      return data.approved === false
    }),
  },
  states: { Editing, Reviewing, Viewing },
})

selectWhen(...) accepts:

  • when: one state creator or a readonly array of state creators
  • second argument: either a select function with shape (data, state, context) => result (runs only when currentState matches when) or a matcher object shorthand
  • optional final options object: { equalityFn?, defaultValue? }
  • function selectors return undefined when currentState does not match when
  • matcher-object selectors return true when all matcher keys equal state.data values, otherwise false
  • when defaultValue is provided, the selector returns it on a non-match instead of undefined/false, which normalizes the otherwise-mixed non-match shape

Prefer matcher-object shorthand when you want a boolean predicate over state.data keys.

A defaultValue is useful when a consumer wants a single stable shape regardless of state:

const reviewerCount = selectWhen(Reviewing, data => data.reviewers.length, {
  defaultValue: 0,
})

Matcher-object shorthand example:

const hasInteractiveLabel = selectWhen([Editing, Reviewing] as const, {
  label: "Interactive",
})

For complex nested matching, discriminated unions, or array/primitive matching, use ts-pattern and pass isMatching(...) directly as the selector function:

import { isMatching } from "ts-pattern"

const hasInteractiveMeta = selectWhen(
  Editing,
  isMatching({ label: "Interactive", meta: { mode: "edit" } }),
)

Install when needed:

npm install ts-pattern

This keeps state checks centralized and colocated with machine definitions, instead of repeating currentState.is(...) branches in components.

In React, useMachine(...) defaults to simple selector reads through machine.selectors. For render-critical paths, set disableAutoSelectors: true and consume values with useSelector(...).

You can evaluate selectors anywhere you have the current state and context, including plain runtime usage outside React:

const runtime = createRuntime(EditorMachine, EditorMachine.states.Viewing())

await runtime.run(enter())

const isEditable = runStateSelector(
  EditorMachine.selectors.isEditable,
  runtime.currentState(),
  runtime.context,
)

If you need to dispatch and read in one step, use runtime.runAndSelect(...) and keep selectors on the machine when the read will be reused.

createParallelMachine

Create a machine root that owns multiple child machines at the same time and broadcasts shared actions to every branch that can handle them.

const parallel = createParallelMachine({
  left: LeftMachine.withInitialState(LeftMachine.states.Loading()),
  right: RightMachine.withInitialState(RightMachine.states.Ready()),
})

await runtime.run(parallel.actions.refresh())

Each branch must be the result of createMachine(...) and must carry its own initialState.

Use this when several child workflows are active together and one parent action should fan out across those branches.

See Parallel State Machines for the full walkthrough and when to choose this instead of stateWithNested(...).

getParallelRuntimes

Read the current child runtime map from a parallel machine state's data.

const runtime = createRuntime(parallel.machine, parallel.initialState)

await runtime.run(enter())

const branches = getParallelRuntimes(runtime.currentState().data)

This is the main helper for integrations that need keyed access to child branch runtimes without reaching into PARALLEL_RUNTIMES directly.

Routing

route

Build a declarative, ordered-guard handler. The builder value is itself a state handler (data, payload, utils) => HandlerReturn<Data>, so you can drop it straight into an Enter slot (a transient, eventless transition) or any action handler slot (a guarded transition on an event). Branches are evaluated top to bottom and the first matching predicate wins; later predicates are not evaluated.

import { log, route, state } from "@tdreyno/fizz"

const cartRoute = route<CartData>()
  .when(data => data.items === 0, EmptyCart)
  .when(
    data => data.coupon != null,
    data => [log("coupon applied"), Discounted(data)],
  )
  .otherwise(ReadyToPay)

const Cart = state<typeof enter, CartData>({ Enter: cartRoute })

route<Data, Payload>(options?) takes the handler's data and payload types as explicit generics. .when(predicate, target, options?) accepts a synchronous, pure predicate (data, payload) => boolean (or a TypeScript type guard (data, payload) => data is Narrowed that narrows the target's data locally). The target receives (data, payload, utils) and may return anything a handler can, including a transition, effect, action, array, or a bare data value (implicit update). It may also be async. A bare BoundStateFn is accepted directly and is called with the current data. The branch options? accepts { id?, label? } for tooling (see getRouteMetadata).

.otherwise(target, options?) is an optional final unconditional branch. When no branch matches and there is no otherwise, the handler returns undefined, which keeps the machine in its current state. An empty route() always stays.

Each .when/.otherwise call returns a new builder, so partial chains can be shared safely.

Strict (unmatched) handling

By default a route with no matching branch and no otherwise silently returns undefined. Pass route({ strict }) or route({ onUnmatched }) to surface unmatched inputs instead:

import { route, RouteUnmatchedError } from "@tdreyno/fizz"

// Throw a RouteUnmatchedError when nothing matches
const strictRoute = route<CartData>({ strict: true })
  .when(data => data.items === 0, EmptyCart)
  .when(data => data.coupon != null, Discounted)

// Or react without throwing
const warnRoute = route<CartData>({ onUnmatched: "warn" }).when(
  data => data.items === 0,
  EmptyCart,
)

// Or provide a custom callback
const customRoute = route<CartData>({
  onUnmatched: ({ data, payload, branches }) => {
    reportMissingRoute(data, branches)
  },
}).when(data => data.items === 0, EmptyCart)

RouteOptions<Data, Payload> is { strict?, onUnmatched? }. onUnmatched is a RouteUnmatchedBehavior: the string "throw", the string "warn" (logs via console.warn and returns undefined), or a function (context: RouteUnmatchedContext) => void. RouteUnmatchedContext<Data, Payload> carries { data, payload, branches } where branches is the same metadata array returned by getRouteMetadata. strict: true is shorthand for onUnmatched: "throw"; an explicit onUnmatched always takes precedence. A matching otherwise() short-circuits before any unmatched behavior runs. RouteUnmatchedError.context exposes the same RouteUnmatchedContext.

This differs from the fluent state .when(...) guard (which guards a single state definition) and from switch_(...) (which keys on state identity and returns a value): route() keys on predicates and produces a transition handler.

getRouteMetadata

Read the ordered branch metadata from a route handler for tooling and introspection. Returns undefined for any value that is not a route handler.

const metadata = getRouteMetadata(cartRoute)
// metadata?.branches => [{ id, label, index, otherwise }, ...] in declaration order

Each branch carries a label (resolved from an explicit { label }, else the target's function name, else a positional fallback such as "branch 2" or "otherwise"), an id (resolved from an explicit { id }, else defaulting to the label), a zero-based index reflecting declaration order, an otherwise flag, and a predicate for non-default branches.

Actions

action

Create a typed action creator, or create an action value directly.

Action creators can have an optional debug label (useful for logging and debugging), or be unnamed with a generated stable ID. Both support withPayload<T>() for type-safe payload definition.

// Named action with optional debug label
const save = action("Save").withPayload<{ id: string }>()

// Unnamed action with generated stable ID
const rename = action().withPayload<{ newName: string }>()

// Create action values directly
save({ id: "1" })
rename({ newName: "Updated" })

Action creators are usable directly as handler keys in state definitions:

const Editing = state({
  [save]: (data, payload, { update }) => update({ ...data, saved: payload.id }),
  [rename]: (data, payload, { update }) =>
    update({ ...data, name: payload.newName }),
})

This approach eliminates duplication of action names and keeps the dispatch API (the creator) and handler map aligned.

enter

Bootstrap a runtime or enter a transitioned state.

await runtime.run(enter())

On the first call to runtime.run(enter()), Fizz performs its internal pre-entry bootstrap before running any Enter handlers.

exit

Represents state exit.

const Closing = state<Exit>({
  Exit: () => log("leaving"),
})

Use this when a state needs to react to being left.

onFrame

Represents an animation-frame tick.

const Spinning = state<Enter | OnFrame>({
  OnFrame: (_data, timestamp) => log(timestamp),
})

Frame scheduling itself is documented in Intervals And Frames.

isAction

Type guard for action values.

if (isAction(value)) {
  console.log(value.type)
}

Use this when you need to narrow unknown input before treating it as a Fizz action.

Context And Runtime

createInitialContext

Create the initial runtime context from a starting state transition.

const context = createInitialContext([Initial()], {
  maxHistory: 10,
  enableLogging: true,
})

The history array must start with at least one state transition.

createRuntime

Create a runtime that can execute actions, transitions, and effects.

- `hasPendingAsync(asyncId)`
- `getPendingAsync(asyncId)`
- `getPendingAsyncCount()`
- `flushAsync(asyncId, options?)`
The returned `Runtime` is the main execution object. The most commonly used methods are:

- `run(action)`
- `runAndSelect(action, selectorOrProject)`
- `runUntil(action, matcher, options?)`
- `waitUntil(matcher, options?)`
- `waitUntilState(stateOrMatcher, options?)`
- `waitUntilOutput(matcher, options?)`
- `currentState()`
- `currentHistory()`
- `currentStatePath(options?)`
- `getVisitedStateNames(options?)`
- `getFlow(separator?)`
- `lastAction()`
- `onContextChange(handler)`
- `onTransition(handler)`
- `onPathTransition(handler, options?)`
- `subscribeSelector(selector, listener, options?)`
- `onOutput(handler)`
- `respondToOutput(type, handler)`
- `bindActions(actions)`
- `disconnect()`
- `getDiagnosticsSnapshot()`
- `assertCleanTeardown(options?)`

Typed output subscription helpers such as `onOutputType(...)` and channel wiring through `connectOutputChannel(...)` are documented in [Output Actions](./output-actions.md).

### `onTransition`

Subscribe to state transitions. The handler fires whenever the machine's state name changes and receives `{ state, previousState, action }`, where `action` is the action that caused the transition (XState `state.event` parity). It returns an unsubscribe function. Unlike `onContextChange(...)` (which fires on every context change, including same-state data updates), `onTransition(...)` fires only when the state name changes.

```ts
const unsubscribe = runtime.onTransition(({ state, previousState, action }) => {
  console.log(`${previousState?.name} -> ${state.name} via ${action?.type}`)
})

// later
unsubscribe()
```

The `RuntimeTransitionInfo` type describes the handler argument and is exported from the package root.

### `onPathTransition`

Subscribe to changes in the composed hierarchical state path (for example `"Connected/Live"`). Unlike `onTransition(...)`, which fires only when the top-level state name changes, `onPathTransition(...)` also fires when a nested child path changes while the top-level name stays the same. The handler receives `{ state, previousState, action, path, previousPath }` and returns an unsubscribe function. An optional second argument forwards `StatePathOptions` (for example a custom `separator`).

```ts
const unsubscribe = runtime.onPathTransition(
  ({ path, previousPath }) => {
    console.log(`${previousPath} -> ${path}`)
    // "Modal/Opening" -> "Modal/Open"
  },
  { separator: "/" },
)

// later
unsubscribe()
```

The `RuntimePathTransitionInfo` type describes the handler argument and is exported from the package root.

### `subscribeSelector`

Subscribe to a `selectWhen(...)` selector and run a listener only when the selected value changes. The selection is re-evaluated on every context change; the listener fires with `(next, previous)` when the new value differs from the previous one by the equality function. Equality resolves from `options.equalityFn`, then the selector's own `equalityFn`, then `Object.is`. Pass `emitInitial: true` to fire once with the current selection at subscribe time. It returns an unsubscribe function.

```ts
const isOpen = selectWhen(Modal, data => data.open, { defaultValue: false })

const unsubscribe = runtime.subscribeSelector(
  isOpen,
  (next, previous) => {
    console.log(`open: ${previous} -> ${next}`)
  },
  { emitInitial: true },
)

// later
unsubscribe()
```

This removes the manual `onContextChange(...)` + `runStateSelector(...)` plumbing that derived-value subscriptions previously required.



Read the most recent triggering action, or `undefined` before the first action runs.

```ts
runtime.lastAction()?.type
```

### `getVisitedStateNames` / `getFlow`

Read the ordered (oldest → newest) state-path names from history, or a single joined flow string, for telemetry and debugging. Nested regions use the composed `getStatePath(...)` form per visited state.

```ts
runtime.getVisitedStateNames() // ["Idle", "Loading", "Ready"]
runtime.getFlow() // "Idle,Loading,Ready"
runtime.getFlow(" -> ") // "Idle -> Loading -> Ready"
```

### `getDiagnosticsSnapshot`

Read a snapshot of active runtime diagnostics. This is useful in tests and debugging when you need to confirm resources were cleaned up.

```ts
const snapshot = runtime.getDiagnosticsSnapshot()

expect(snapshot).toEqual({
  asyncOps: [],
  channelQueues: [],
  listeners: [],
  resources: [],
  timers: [],
})

Snapshot groups:

  • listeners: normalized listener counts by target/type
  • resources: active state resources (key, stateName)
  • timers: active timeout/interval/frame entries
  • asyncOps: active async or debounced operation ids
  • channelQueues: pending imperative command queue depth per channel

assertCleanTeardown

Assert that teardown is clean by throwing when any diagnostics group still has active entries.

runtime.assertCleanTeardown()

runtime.assertCleanTeardown({
  allow: {
    timers: true,
  },
})

Use allow when a test intentionally tolerates specific groups.

Runtime creation options include:

  • maxHistory
  • enableLogging
  • customLogger
  • monitor
  • asyncDriver
  • timerDriver
  • browserDriver

Use browserDriver when your machine returns browser-oriented built-in effects such as confirm(...), prompt(...), alert(...), copyToClipboard(...), or navigation helpers.

Import browser runtime drivers from the browser entrypoint only: @tdreyno/fizz/browser.

getSnapshot

Capture a serializable snapshot of a runtime's current state and bounded history (newest first). Live handles (nested/parallel runtimes, resources) are recursively captured as child snapshots, never serialized directly.

const snapshot = getSnapshot(runtime, {
  maxHistory: 10, // optional cap on captured entries
  machineName: "Cart", // optional label stored on the snapshot
})

restoreRuntime

Rebuild a live runtime from a snapshot. Looks up each history entry's state by name (named states required), preserves append/update modes, and by default re-runs the restored state's enter() lifecycle so timers, subscriptions, and nested/parallel children re-establish. Throws SnapshotRestoreError on unknown state names or version mismatch.

const runtime = await restoreRuntime(CartMachine, snapshot, {
  runLifecycle: true, // default; false skips enter()
  maxHistory: 10, // plus any other createRuntime option
})

serializeSnapshot / parseSnapshot

JSON round-trip helpers with shape and version validation. Pass replacer/reviver for non-JSON-safe state data.

const json = serializeSnapshot(snapshot, { space: 2 })
const roundTripped = parseSnapshot(json)

See Persistence for the full guide.

hasPendingAsync, getPendingAsync, getPendingAsyncCount, flushAsync

Observe and control pending async work from outside the state machine.

// Check if any work is pending for an asyncId
runtime.hasPendingAsync("save") // boolean

// Get a snapshot of the current phase
runtime.getPendingAsync("save")
// { asyncId: "save", phase: "debouncing" } | { asyncId: "save", phase: "in-flight" } | undefined

// Count all pending operations (debouncing + in-flight)
runtime.getPendingAsyncCount() // number

// Flush a pending debounce immediately (or wait for in-flight work) and get the outcome
const outcome = await runtime.flushAsync("save")
// { type: "nothing" } | { type: "succeeded"; value: unknown } | { type: "failed"; error: unknown } | { type: "aborted" }

flushAsync(asyncId, options?) supports an optional timeoutMs:

const outcome = await runtime.flushAsync("save", { timeoutMs: 3000 })

See Async for full documentation and examples.

runAndSelect

Dispatch an action, wait for the same completion boundary as run(action), then read a derived value from the resulting state.

Prefer the selector form when the derived read belongs with the machine definition:

const machine = createMachine({
  actions: { localChanged },
  selectors: {
    renderInputs: selectWhen(Editing, data => ({
      canSave: data.value.length > 0,
      preview: data.value.trim(),
    })),
  },
  states: { Editing, Viewing },
})

const runtime = createRuntime(machine, Viewing({ value: "" }))

const renderInputs = await runtime.runAndSelect(
  localChanged({ value: " draft text " }),
  machine.selectors.renderInputs,
)

For narrow one-off adapter logic, pass a projection function instead:

const renderInputs = await runtime.runAndSelect(
  localChanged({ value: " draft text " }),
  state => {
    if (!state.is(Editing)) {
      return { canSave: false, preview: "" }
    }

    return {
      canSave: state.data.value.length > 0,
      preview: state.data.value.trim(),
    }
  },
)

runAndSelect(...) does not wait for async effect settlement. It resolves after the same synchronous transition and effect work as run(...), then reads from the final current state.

See Dispatch And Read for guidance on when to use selectors vs inline projections.

waitUntil, waitUntilState, waitUntilOutput, runUntil

Await a state, output, or compound condition with a single Promise.

import { matchOutput, matchState } from "@tdreyno/fizz"

// Sugar for state and output matchers.
const ready = await runtime.waitUntilState(States.Ready)
const saved = await runtime.waitUntilOutput(savedAction)

// `runUntil` subscribes before dispatching so synchronous transitions
// are not missed.
const result = await runtime.runUntil(
  save(),
  matchOutput({
    Saved: true,
    Failed: false,
  }),
)

All four accept the same options object:

  • signal: AbortSignal; rejects with WaitUntilAbortError.
  • timeout: milliseconds; rejects with WaitUntilTimeoutError.
  • includeCurrent: defaults to true for state matchers; resolves via microtask if the current state already matches.

Pending waits reject with RuntimeDisconnectedError if the runtime disconnects.

See Awaiting Conditions for the matcher helpers (matchState, matchOutput, matchAny) and cancellation patterns.

createRuntimeRegistry

Create a keyed registry for non-React runtime lifecycle management.

const registry = createRuntimeRegistry<string | object, Runtime<any, any>>()

const runtime = registry.getOrCreate(rootElement, () => {
  const created = createRuntime(machine, Initial())

  void created.run(enter())

  return created
})

registry.dispose(rootElement)

Registry methods:

  • getOrCreate(key, init)
  • get(key)
  • has(key)
  • dispose(key)
  • disposeAll()
  • values()

Options:

  • disposeRuntime (optional): custom disposal callback. By default, calls value.disconnect() when present.
  • onLifecycleEvent (optional): receives created, reused, disposed, and dispose-error events.
  • removeOnFailure (optional, default true): when disposal fails, remove the entry anyway.

Use this utility when you need deterministic keyed reuse and explicit teardown outside React.

Effects

effect

Create a custom effect.

const saveDraft = effect("saveDraft", { id: "1" }, context => {
  context.customLogger?.(["saved"], "log")
})

Use this when the built-in helpers do not fit and you need an explicit effect object.

goBack

Transition to the previous state in history.

const Details = state({
  Cancel: () => goBack(),
})

output

Emit an output action to runtime.onOutput(...) subscribers.

const saved = action("Saved")

const Saving = state<Enter>({
  Enter: () => output(saved()),
})

For defineOutputMap(...), outputCommand(...), and runtime output channel wiring, see Output Actions.

commandEffect

Create a typed imperative command effect and optionally chain command results/errors into actions.

type Commands = {
  notesEditor: {
    setDocument: {
      payload: { document: string }
      result: { saved: true }
    }
  }
}

const applySucceeded = action("ApplySucceeded")
const applyFailed = action("ApplyFailed").withPayload<{ message: string }>()

const Editing = state({
  ApplyClicked: (_data, payload) =>
    commandEffect<Commands, "notesEditor", "setDocument">(
      "notesEditor",
      "setDocument",
      { document: payload.document },
      { latestOnlyKey: "set-document" },
    ).chainToAction(
      () => applySucceeded(),
      error =>
        applyFailed({
          message: error instanceof Error ? error.message : "Unknown error",
        }),
    ),
})

Register command handlers with createRuntime(..., { commandHandlers }).

commandEffect(...) optional fourth argument:

  • latestOnlyKey?: when present, pending same-channel commands with the same key are replaced by the newest queued command before execution
const runtime = createRuntime(machine, Editing(initialData), {
  commandHandlers: {
    notesEditor: {
      setDocument: async payload => {
        await editorAdapter.setDocument(payload.document)

        return { saved: true as const }
      },
    },
  },
})

When your runtime already injects adapter objects through clients, derive a typed handler map with commandHandlersFromClients(...).

const clients = {
  notesEditor: {
    setDocument: async (payload: { document: string }) => {
      await editorAdapter.setDocument(payload.document)

      return { saved: true as const }
    },
  },
}

const runtime = createRuntime(machine, Editing(initialData), {
  clients,
  commandHandlers: commandHandlersFromClients<Commands>(clients),
})

commandChannel

Bind a command channel once, then create channel-scoped commands and batches without repeating channel strings. commandChannel(...) accepts an optional scheduling policy that controls how queued commands behave when multiple arrive for the same key.

// FIFO: default when no options are passed
const sessionCommands = commandChannel<Commands, "session">("session")

// Replace pending: new command supersedes a queued-but-not-yet-running command
const editorCommands = commandChannel<Commands, "notesEditor">("notesEditor", {
  scheduling: { mode: "replace-pending", keyPrefix: "editor" },
})

// Replace pending AND cancel running: aborts the currently executing handler
const dragCommands = commandChannel<Commands, "drag">("drag", {
  scheduling: {
    mode: "replace-pending-and-cancel-running",
    keyPrefix: "drag",
    // Optional per-command key overrides (two types sharing the same key will coalesce):
    commands: {
      updatePreview: { key: "drag-frame" },
      restoreGeometry: { key: "drag-frame" },
    },
  },
})
const Editing = state({
  ApplyRemote: (_data, payload) =>
    editorCommands
      .batch([
        editorCommands.command("setDocument", {
          document: payload.document,
        }),
        editorCommands.command("setEditable", {
          editable: payload.editable,
        }),
      ])
      .chainToAction(applySucceeded(), () => applyFailed()),
})

commandChannel(...) methods:

  • command(type, payload?): creates a commandEffect(...) for the bound channel. Payload may be omitted when the schema declares it as void or undefined.
  • batch(commands, options?): creates an effectBatch(...) with the bound channel

Scheduling policies

Mode Behavior
"fifo" (default) Commands run in arrival order; no coalescing
"replace-pending" A queued (not yet running) command is replaced by a newer one with the same key
"replace-pending-and-cancel-running" Same as above, plus the currently executing handler receives an aborted AbortSignal

Key derivation: each command gets the key <keyPrefix>-<commandType> unless an explicit per-command override is provided via commands.<type>.key.

Handlers and AbortSignal

All command handlers receive a second argument { signal: AbortSignal }:

const commandHandlers = {
  drag: {
    async updatePreview(payload, { signal }) {
      await waitForFrame(signal)
      if (signal.aborted) return
      applyPreview(payload)
    },
  },
}

The signal is aborted when a replace-pending-and-cancel-running channel cancels the running task. For fifo and replace-pending channels the signal is never aborted.

Use this helper when the same channel appears repeatedly in one state or machine. For narrative guidance and runtime subscription patterns, see Output Actions.

effectBatch

Run multiple commandEffect(...) items as one ordered batch.

const applySucceeded = action("ApplySucceeded")
const applyFailed = action("ApplyFailed")

const Editing = state({
  ApplyRemote: (_data, payload) =>
    effectBatch(
      [
        commandEffect<Commands, "notesEditor", "setDocument">(
          "notesEditor",
          "setDocument",
          { document: payload.document },
        ),
        commandEffect<Commands, "notesEditor", "setEditable">(
          "notesEditor",
          "setEditable",
          { editable: payload.editable },
        ),
      ],
      {
        channel: "editor",
        onError: "continue",
      },
    ).chainToAction(applySucceeded(), () => applyFailed()),
})

effectBatch options:

  • channel?: optional serialization key; batches with the same channel execute without interleaving
  • onError?: "failBatch" | "continue" (defaults to "failBatch")

effectBatch chaining:

  • chainToAction(resolveAction, reject?): map batch completion/failure into internal actions
  • chainToOutput(resolveOutputAction, reject?): map batch completion/failure into emitted outputs

Current behavior:

  • batches run child effects in listed order
  • effectBatch currently accepts commandEffect(...) entries
  • same-channel batches are serialized; omitted channel keeps normal queue behavior

log

Create a logging effect.

const Loading = state<Enter>({
  Enter: () => log("loading"),
})

warn

Create a warning effect.

const Editing = state({
  Invalid: () => warn("missing title"),
})

error

Create an error logging effect.

const Failed = state({
  Enter: () => error("request failed"),
})

noop

Create an intentional no-op effect.

const Idle = state({
  Ping: () => noop(),
})

resource

Register a state-scoped resource value, optionally with teardown.

const Editing = state({
  Enter: () => resource("sessionId", crypto.randomUUID()),
})

resource(...) also supports fluent event bridging with .bridge(options).chainToAction(...).

For bridge options, lifecycle behavior, and full examples, see State Resources.

subscription

Register a state-scoped subscription teardown directly.

const Editing = state({
  Enter: () =>
    subscription("unsubscribePresence", () =>
      presenceStore.subscribe(() => {}),
    ),
})

See State Resources for lifecycle and resource ownership details.

abortController

Register an AbortController resource with automatic abort() on cleanup.

const Editing = state({
  Enter: () => abortController("requestAc"),
})

See State Resources for state-scoped resource semantics.

Browser effects

Fizz exposes first-party browser effect helpers:

  • confirm(message)
  • prompt(message)
  • alert(message)
  • copyToClipboard(text)
  • openUrl(url, target?, features?)
  • printPage()
  • locationAssign(url)
  • locationReplace(url)
  • locationReload()
  • historyBack()
  • historyForward()
  • historyGo(delta)
  • postMessage(message, targetOrigin, transfer?)

confirm(...) and prompt(...) are runtime-owned request/response primitives. They resolve back into built-in actions that can be handled directly in states:

  • ConfirmAccepted
  • ConfirmRejected
  • PromptSubmitted
  • PromptCancelled

The one-way browser helpers (alert, copy, open, print, location, history, postMessage) do not emit follow-up actions.

Async effects

Fizz exposes three first-party async effect helpers:

  • startAsync(run, asyncId?)
  • cancelAsync(asyncId)
  • debounceAsync(run, options).chainToAction(resolve, reject?)

Use startAsync(...) for immediate async work, cancelAsync(...) to stop a known async lane, and debounceAsync(...) when the machine needs debounce plus latest-wins cancellation semantics in one helper.

Use startAsync(...).chainToAction(resolve, reject) when settled async work should dispatch actions. Return startAsync(...) directly for fire-and-forget work.

debounceAsync(...) requires a lazy run function and a required asyncId:

debounceAsync(signal => saveDraft(signal), {
  asyncId: "draft",
  delayMs: 300,
}).chainToAction(saveSucceeded, saveFailed)

Option shape:

type DebounceAsyncOptions<AsyncId extends string> = {
  asyncId: AsyncId
  delayMs: number
  classifyAbort?: (reason: unknown, signal: AbortSignal) => boolean
  emitCancelled?: boolean
}

debounceAsync(...).chainToAction(resolve, reject?) requires resolve and allows an omitted reject mapper.

Behavior summary:

  • new work for the same asyncId replaces pending debounce state
  • a replacement cancels any currently running request on that same id
  • cancelAsync(asyncId) cancels pending debounce state and running work
  • stale completions are ignored automatically
  • abort-classified failures skip the reject mapper

requestJSONAsync and customJSONAsync retry policy

Both JSON async builders support an optional retry policy in their init argument.

Use this when a request or client callback should retry with fixed or exponential backoff.

requestJSONAsync("/api/profile", {
  retry: {
    attempts: 4,
    strategy: {
      kind: "exponential",
      baseDelayMs: 200,
      maxDelayMs: 2000,
    },
  },
})

See Async for complete retry policy options and examples.

States

state

Create a state definition from a map of action handlers.

const finish = action("Finish")

const Start = state<Enter | ReturnType<typeof finish>>(
  {
    Finish: () => Done({ done: true }),
  },
  { name: "Start" },
)

const Done = state<Enter, { done: boolean }>({}, { name: "Done" })

state(...) is the main authoring API. Each handler receives (data, payload, utils) and can return a transition, an action, an effect, an array of returns, or a promise of those values. The returned array is flattened one level, so helpers that produce groups of effects (for example dom.listen(...), the various dom.…onEvent(...) helpers, and the branch returns inside whichTimeout(...) / whichInterval(...)) can be composed inline without the ... spread operator.

For object data states, a single plain-object return is shorthand for update(nextData). update(nextData) remains the explicit and always-supported form.

Shorthand guardrails:

  • shorthand applies only to a single plain-object return
  • array data states still require explicit update(...)
  • plain objects inside returned arrays are not treated as updates

If you prefer draft-style nested edits, you can optionally compute nextData with Immer produce(...) and pass that result to update(...).

import { produce } from "immer"

const Editing = state({
  SetStreet: (data, payload, { update }) =>
    update(
      produce(data, draft => {
        draft.profile.address.street = payload.street
      }),
    ),
})

This pattern is optional and does not change the Fizz runtime API. See Fluent API for the same approach in fluent-style state definitions.

stateWithNested

Create a state that owns an embedded child runtime.

const Parent = stateWithNested(
  {
    Save: data => updateParent(data),
  },
  ChildStates.Initial(),
  ChildActions,
  { name: "Parent" },
)

Use this when nested composition makes the machine easier to reason about, rather than only avoiding a few repeated handlers.

The second argument (the child's initial state) may be a StateTransition or a resolver function (data) => StateTransition. Fizz calls the resolver with the parent's data when the parent enters, so the child region can start at a different leaf depending on the parent's data.

The optional fourth argument controls forwarding in addition to name:

  • forward?: "all" | "none" | Array<keyof NestedActions>"all" (default) forwards every action in the map, "none" forwards none, and an array forwards only the listed names. Excluded actions may still be handled by an explicit parent handler.
  • mapPayload?: { [K]?: (payload, data) => payload } — rewrite a forwarded action's payload before the child runtime receives it.
  • beforeForward? / afterForward?: (info: { action, payload, data }) => void — side-effecting observers invoked around the child run(...); payload is the mapped payload. For observation only.
  • states?: Record<string, BoundStateFn> — child-state lookup by name, required to restore a persistence snapshot into the nested region.

Nested child handlers receive utils.resources with automatic fallback to resources owned by the parent stateWithNested(...) state. Child resources take precedence when keys overlap.

See Nested State Machines for a practical walkthrough of parent and child communication. If the problem is several active child workflows instead of one parent-owned child workflow, use Parallel State Machines instead.

getStatePath

Build a composed, hierarchical path string for a state and any nested child regions it owns. Exported from both @tdreyno/fizz and @tdreyno/fizz/nested.

import { getStatePath } from "@tdreyno/fizz"

getStatePath(runtime.currentState()) // "Parent/Child"
getStatePath(runtime) // "Parent/Child"
getStatePath(runtime.currentState(), { separator: "." }) // "Parent.Child"
getStatePath(FlatState()) // "FlatState"

Accepts either a state transition or anything exposing a currentState() accessor (such as a runtime). It walks the nested child runtime stored under the NESTED symbol on each level's data, joining the state names with options.separator (default "/"). A flat state returns just its name; a non-state value returns "". This mirrors XState's state.toStrings() for logging and analytics.

debounce

Wrap a handler so it only runs after a quiet period.

const Editing = state({
  InputChanged: debounce((data, payload, { update }) => {
    return update({ ...data, value: payload })
  }, 250),
})

throttle

Wrap a handler so it cannot run more often than the configured interval.

const Connected = state({
  Tick: throttle((_data, _payload, { trigger }) => {
    trigger(sync())
  }, 1000),
})

switch_

Pattern-match on the current state value.

const label = switch_(runtime.currentState())
  .case_(Idle, () => "idle")
  .case_(Saving, data => `saving ${data.id}`)
  .run()

whichTimeout

Build a typed timeout-id matcher. Each branch narrows payload.timeoutId to its specific id. Branches are optional: a timer id without a branch resolves to undefined (a no-op), matching how state(...) handles actions without a registered handler.

type TimeoutId = "autosave" | "banner"

const Editing = state<Enter, { saved: boolean }, TimeoutId>({
  TimerCompleted: whichTimeout<TimeoutId>({
    autosave: (data, _payload, { update }) => update({ ...data, saved: true }),
  }),
})

Timer behavior is covered in Timers.

whichInterval

Build a typed interval-id matcher. Each branch narrows payload.intervalId to its specific id. Branches are optional: an interval id without a branch resolves to undefined (a no-op).

type IntervalId = "presence" | "sync"

const Connected = state<Enter, { ticks: number }, never, IntervalId>({
  IntervalTriggered: whichInterval<IntervalId>({
    presence: (data, _payload, { update }) =>
      update({ ...data, ticks: data.ticks + 1 }),
    sync: data => data,
  }),
})

Interval and frame behavior is covered in Intervals And Frames.

waitState

Create a state that emits a request action on entry and waits for a matching response action.

const LoadProfile = waitState(
  loadProfile,
  profileLoaded,
  (data, payload) => Ready({ ...data, profile: payload }),
  { name: "LoadProfile", timeout: 5000 },
)

Type Guards

isEffect

Type guard for effect values.

if (isEffect(value)) {
  console.log(value.label)
}

isStateTransition

Type guard for compiled state transitions.

if (isStateTransition(value)) {
  console.log(value.name)
}