XState-first rule: Before designing any library feature or changing any API, look up how XState v5 handles the same pattern and align to it. Never invent new API shapes.
Core imports live in packages/ui/src/mosaic/machine/.
import { setup } from './setup'; // primary: pre-binds TContext + TEvent
import { createActor, mockActor } from './createActor';
import { useMachine, useActor, useSelector } from './useMachine';
// Lower-level (only when not using setup):
import { createMachine } from './createMachine';
import { assign } from './assign';
setup<TContext, TEvent>() returns { createMachine, assign, fromPromise }. Use fromPromise for promise-based invoke configurations — it carries the resolved type to e.output in onDone.actions.
Anatomy
Use setup<TContext, TEvent>() at the top of each machine file. It pre-binds
both type parameters, returning a typed createMachine and assign so you
never have to restate them at call sites.
import { setup } from './setup';
// 1. Define context type — flat object, null defaults for optional fields.
interface MyContext {
data: string | null;
error: string | null;
}
// 2. Define the event union — SCREAMING_SNAKE_CASE types.
type MyEvent = { type: 'FETCH' } | { type: 'RETRY' } | { type: 'RESET' };
// 3. Pre-bind types once for the file.
const { createMachine, assign, fromPromise } = setup<MyContext, MyEvent>();
// 4. Factory when async deps are needed; plain createMachine() when not.
export function createMyMachine(fetchData: () => Promise<string>) {
return createMachine({
// no <MyContext, MyEvent> needed
id: 'my',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: {
on: { FETCH: 'loading' },
},
loading: {
// fromPromise carries the resolved type to e.output in onDone.actions.
// A raw src function also works — e.output is `any` in that case.
invoke: fromPromise(() => fetchData(), {
onDone: {
target: 'success',
// e.output: string — typed from fetchData's return type, no cast needed
actions: assign((_, e) => ({ data: e.output, error: null })),
},
onError: {
target: 'failure',
// e: ErrorInvokeEvent — inferred, no import needed
actions: assign((_, e) => ({ error: String(e.error) })),
},
}),
},
success: { type: 'final' },
failure: {
on: { RETRY: 'loading', RESET: 'idle' },
},
},
});
}
assign's second type parameter is inferred from its position:
- Inside
on['SOME_EVENT']→ narrowed to that event member (e.g.e.valueis safe) - Inside
fromPromise(...).onDone→DoneInvokeEvent<TOutput>whereTOutputis the src return type - Inside
onError→ErrorInvokeEvent - Inside
after[delay]→AfterEvent
You do not need to import or write DoneInvokeEvent, ErrorInvokeEvent, AfterEvent,
or Extract<Event, { type: 'X' }> in machine files.
Do's
Model states, not booleans. Replace isOpen + isDeleting + isError with explicit states — idle → confirming → deleting → deleted. Impossible combinations become unrepresentable.
Define machines at module level or in a factory function. They're static descriptions; creating inside a component recreates the object on every render (harmless for useMachine due to its useRef guard, but confusing and wasteful).
Inject async deps via a factory, not module-level closure.
// ✓ factory — testable, no import-time side effects
export const createDeleteOrgMachine = (destroyFn: () => Promise<void>) => createMachine({ ... });
// ✗ module-level capture — hard to test, couples to module load order
const machine = createMachine({ states: { deleting: { invoke: { src: () => someGlobal.destroy() } } } });
Use assign for context updates. It's a pure (context, event) => Partial<context> — the runtime merges the patch.
Use invoke for async work. Actions are synchronous side effects only; promises in actions are invisible to the machine.
Gate navigation with state-node guard. Every transition targeting the state checks it automatically — no per-transition boilerplate.
states: {
step2: {
guard: (ctx) => ctx.step1Complete, // blocks all entry to step2
on: { NEXT: 'step3', PREV: 'step1' },
},
}
Test in plain JS. Drive createActor → start → send with no React. Reach unreachable/transient states with mockActor:
const actor = mockActor(machine, { value: 'deleting', context: { error: null } });
expect(actor.getSnapshot().value).toBe('deleting');
Use actor.recheck() when external data a guard reads changes. It re-seats to the derived initial if the current state's guard no longer holds, or fires any pending always transition.
Don'ts
Don't do async work in actions. Promises returned from an action function are dropped — the machine never sees the resolved value.
Don't mutate context directly in actions. Side effects only; use assign to update context.
Don't track "impossible" state in context. If you find yourself checking isDeleting && isOpen, add a state instead of adding a guard on a context flag.
Don't pass an async function captured at module definition time. It can't be stubbed in tests, and it breaks the pattern of injecting live props.
React patterns
useMachine — own a flow for the component's lifetime
function DeleteOrganization({ organization }: { organization: Org }) {
const [snapshot, send] = useMachine(deleteOrgMachine, {
// `context` is kept current via useLayoutEffect — safe to pass live props/functions.
context: { destroyFn: () => organization.destroy() },
// `onDone` fires once when the machine reaches a `type: 'final'` state.
onDone: () => router.navigate('/dashboard'),
});
return (
<ConfirmDialog
open={snapshot.value === 'confirming' || snapshot.value === 'deleting'}
onOpenChange={isOpen => send({ type: isOpen ? 'OPEN' : 'CANCEL' })}
isDeleting={snapshot.value === 'deleting'}
onConfirm={() => send({ type: 'CONFIRM' })}
error={snapshot.context.error}
/>
);
}
Branch on snapshot.value for UI, not on snapshot.context booleans.
onDone always calls the latest prop — no stale-closure risk. Do not replace it with a useEffect watching snapshot.status.
useActor — bind to a shared actor
Use when the actor's lifecycle is owned by a parent or context provider.
function StepIndicator({ actor }: { actor: WizardActor }) {
const [snapshot] = useActor(actor);
return <Breadcrumb currentStep={snapshot.value} />;
}
useSelector — subscribe to a slice
Re-renders only when the selected value changes (by Object.is). Primary way to consume a shared actor without full-snapshot coupling.
const error = useSelector(actor, snap => snap.context.error);
const isDeleting = useSelector(actor, snap => snap.value === 'deleting');
Injecting live props
useMachine calls actor.setContext(options.context) via useLayoutEffect after every render. Pass functions from props without recreating the machine:
// The machine reads `ctx.onSuccess` — always the latest prop.
const [snapshot, send] = useMachine(machine, { context: { onSuccess: props.onSuccess } });
Debug logging (remove before shipping)
import { useMachineLogger } from './useMachine';
const [snapshot, send] = useMachine(machine);
useMachineLogger('myFlow', snapshot); // logs: [myFlow] idle → loading { data: null }