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.
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" }),
)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
selectfunction with shape(data, state, context) => result(runs only whencurrentStatematcheswhen) or a matcher object shorthand - optional final
optionsobject:{ equalityFn?, defaultValue? } - function selectors return
undefinedwhencurrentStatedoes not matchwhen - matcher-object selectors return
truewhen all matcher keys equalstate.datavalues, otherwisefalse - when
defaultValueis provided, the selector returns it on a non-match instead ofundefined/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-patternThis 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.
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(...).
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.
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.
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.
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 orderEach 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.
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.
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.
Represents state exit.
const Closing = state<Exit>({
Exit: () => log("leaving"),
})Use this when a state needs to react to being left.
Represents an animation-frame tick.
const Spinning = state<Enter | OnFrame>({
OnFrame: (_data, timestamp) => log(timestamp),
})Frame scheduling itself is documented in Intervals And Frames.
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.
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.
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/typeresources: active state resources (key,stateName)timers: active timeout/interval/frame entriesasyncOps: active async or debounced operation idschannelQueues: pending imperative command queue depth per channel
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:
maxHistoryenableLoggingcustomLoggermonitorasyncDrivertimerDriverbrowserDriver
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.
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
})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
})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.
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.
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.
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 withWaitUntilAbortError.timeout: milliseconds; rejects withWaitUntilTimeoutError.includeCurrent: defaults totruefor 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.
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, callsvalue.disconnect()when present.onLifecycleEvent(optional): receivescreated,reused,disposed, anddispose-errorevents.removeOnFailure(optional, defaulttrue): when disposal fails, remove the entry anyway.
Use this utility when you need deterministic keyed reuse and explicit teardown outside React.
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.
Transition to the previous state in history.
const Details = state({
Cancel: () => goBack(),
})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.
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),
})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 acommandEffect(...)for the bound channel. Payload may be omitted when the schema declares it asvoidorundefined.batch(commands, options?): creates aneffectBatch(...)with the bound channel
| 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.
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.
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 interleavingonError?:"failBatch" | "continue"(defaults to"failBatch")
effectBatch chaining:
chainToAction(resolveAction, reject?): map batch completion/failure into internal actionschainToOutput(resolveOutputAction, reject?): map batch completion/failure into emitted outputs
Current behavior:
- batches run child effects in listed order
effectBatchcurrently acceptscommandEffect(...)entries- same-channel batches are serialized; omitted
channelkeeps normal queue behavior
Create a logging effect.
const Loading = state<Enter>({
Enter: () => log("loading"),
})Create a warning effect.
const Editing = state({
Invalid: () => warn("missing title"),
})Create an error logging effect.
const Failed = state({
Enter: () => error("request failed"),
})Create an intentional no-op effect.
const Idle = state({
Ping: () => noop(),
})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.
Register a state-scoped subscription teardown directly.
const Editing = state({
Enter: () =>
subscription("unsubscribePresence", () =>
presenceStore.subscribe(() => {}),
),
})See State Resources for lifecycle and resource ownership details.
Register an AbortController resource with automatic abort() on cleanup.
const Editing = state({
Enter: () => abortController("requestAc"),
})See State Resources for state-scoped resource semantics.
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:
ConfirmAcceptedConfirmRejectedPromptSubmittedPromptCancelled
The one-way browser helpers (alert, copy, open, print, location, history, postMessage) do not emit follow-up actions.
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
asyncIdreplaces 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
rejectmapper
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.
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.
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 childrun(...);payloadis 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.
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.
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),
})Wrap a handler so it cannot run more often than the configured interval.
const Connected = state({
Tick: throttle((_data, _payload, { trigger }) => {
trigger(sync())
}, 1000),
})Pattern-match on the current state value.
const label = switch_(runtime.currentState())
.case_(Idle, () => "idle")
.case_(Saving, data => `saving ${data.id}`)
.run()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.
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.
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 guard for effect values.
if (isEffect(value)) {
console.log(value.label)
}Type guard for compiled state transitions.
if (isStateTransition(value)) {
console.log(value.name)
}