name: xstate-helper description: | XState v5 state machines, statecharts, and the actor model for complex, event-driven application logic. When user works with XState, state machines, statecharts, actors, createMachine/setup, createActor, @xstate/react, @xstate/store, useMachine/useActor/useSelector, invoke/spawn, guards/actions/assign, or needs to model complex async flows, wizards, and event-driven state.
XState Helper Agent
Guidance for XState v5 — state machines, statecharts, and actors. This monorepo uses Bun and a
strict ESLint config; read the Monorepo Gotchas section before writing any machine here — the
documented setup({ types }) idiom from the official docs does not lint-pass in this repo and
there is a specific workaround.
Versions (verified mid-2026)
| Package | Latest | Notes |
|---|---|---|
xstate |
5.32.0 | Core. In-repo use: discord-plays-pokemon/.../backend pins ^5.32.0 |
@xstate/react |
6.1.0 | React hooks. peer: react 16.8–19, xstate ^5.28 |
@xstate/store |
4.1.0 | Lightweight store (Zustand-like). React binding is a separate package |
@xstate/store-react |
2.0.0 | React binding for store v4 — @xstate/store/react was removed in v4 |
bun add xstate # core
bun add @xstate/react # React bindings
bun add @xstate/store @xstate/store-react # lightweight store + its React binding
Requires TypeScript 5.0+. In tsconfig.json set "strictNullChecks": true (required — types
break without it) and "skipLibCheck": true (recommended).
What's New in XState v5 (2024–2026)
v5 made actors first-class, removed v4 magic (typegen, implicit string events, external-by-default
transitions), and added setup() for type safety. Recent 5.x minors:
actor.select(selector, eq?)(5.29) — derive a framework-agnosticReadable<T>off any actor's snapshot;.get()+.subscribe()(only fires when the selected value changes). No hook needed.- Routable states (5.28) — a state with
route: {}+ explicitidis reachable from anywhere via{ type: 'xstate.route', to: '#id' }. Routes accept aguard(string ref resolved since 5.31.1). getMicrosteps()/getInitialMicrosteps()(5.27) — pure[snapshot, actions]tuples per microstep, so you can inspect every intermediate (incl.always) state without executing actions.maxIterations(5.31) — infinite-loop guard for eventless transitions (defaultInfinity).filterEvents(5.30) —xstate/graph+createTestModeltraversal limited to currently-enabled events (e.g.state.can(event)).setup().assign/raise/sendTo/emit/spawnChild/enqueueActions(...)(5.22) — type-bound action helpers; no inline generics. Current best practice for typedenqueueActions/emit/spawnChild.setup.extend()(5.24) andsetup().createStateConfig()(5.21) — composable, modular setups.mapState(snapshot, mapper)(5.31),getNextTransitions(snapshot)(5.26), partial descriptors inassertEvent(event, 'FEEDBACK.*')(5.25).- Model-based testing moved into core — import from
xstate/graph(@xstate/testis deprecated). @xstate/storev3/v4 —store.trigger.someEvent(...)typed API,.with(persist({ name })), atoms, schema validation; v4 split framework bindings into@xstate/store-reactetc.
Core Mental Model
Everything is an actor. An actor is a live entity with private state, a mailbox (one event at a time), and async message passing. A state machine is the most robust way to describe an actor's behavior — but
fromPromise,fromCallback,fromObservable, andfromTransitionare actor logic too.
- Logic (the machine/definition) is inert.
createActor(logic)makes a running actor. actor.send({ type })— events are objects, never bare strings (v4 allowed strings; v5 does not).actor.getSnapshot()reads current state;actor.subscribe(fn)observes changes (does not emit the current value immediately — readgetSnapshot()for that).
import { createMachine, createActor } from "xstate";
const toggleMachine = createMachine({
id: "toggle",
initial: "inactive",
states: {
inactive: { on: { toggle: { target: "active" } } },
active: { on: { toggle: { target: "inactive" } } },
},
});
const actor = createActor(toggleMachine);
actor.subscribe((snapshot) => console.log(snapshot.value));
actor.start(); // logs "inactive"
actor.send({ type: "toggle" }); // logs "active"
actor.stop();
Machine vs Store — pick the right tool
- State machine (
xstate) — many discrete states, guarded transitions, hierarchy/parallelism, async orchestration. Use when the transitions are the hard part (wizards, checkout, auth, media players). - Store (
@xstate/store) — a small, event-based shared-state container (Zustand-like) when you do not need statecharts. Seereferences/xstate-store.md. - Not XState at all — for a bag of independent values prefer Zustand/Jotai; for server cache use
TanStack Query. A localized form/wizard reducer can just be
useReducer. XState earns its weight when illegal states and complex transitions are the actual problem (it makes impossible states unrepresentable). Don't reach for it to hold three booleans.
setup() + createMachine — the typed entry point
setup({ types, actors, guards, actions, delays }) registers named implementations and types, then
.createMachine(config) references them by string. This is the v5 idiom (v5 has no typegen).
import { setup, assign, fromPromise } from "xstate";
// ⚠️ In THIS repo, do NOT inline `context: {} as Ctx` — see Monorepo Gotchas below.
const machine = setup({
types: {
context: {} as { count: number; user: string | undefined },
events: {} as { type: "inc"; by: number } | { type: "reset" } | { type: "LOAD" },
input: {} as { start: number },
},
actors: {
loadUser: fromPromise(async ({ input }: { input: { id: number } }) => {
const res = await fetch(`/api/users/${input.id}`);
return res.json();
}),
},
guards: {
canInc: ({ context }) => context.count < 100,
},
actions: {
increment: assign({ count: ({ context, event }) => {
// narrow the event to access its payload safely
return event.type === "inc" ? context.count + event.by : context.count;
} }),
},
}).createMachine({
id: "counter",
initial: "idle",
context: ({ input }) => ({ count: input.start, user: undefined }),
states: {
idle: {
on: {
inc: { guard: "canInc", actions: "increment" },
reset: { actions: assign({ count: 0 }) },
LOAD: { target: "loading" },
},
},
loading: {
invoke: {
src: "loadUser",
input: { id: 1 },
onDone: { target: "idle", actions: assign({ user: ({ event }) => event.output.name }) },
onError: { target: "idle" },
},
},
},
});
const actor = createActor(machine, { input: { start: 0 } }).start();
actor.send({ type: "inc", by: 5 });
Override implementations per instance with machine.provide({ actions, actors, guards, delays })
(replaces v4 withConfig). See references/actors-and-machines.md for the full machine/actor surface
(hierarchical & parallel states, history, after, always, raise, enqueueActions, emit, spawn).
Context, Actions & Guards (quick reference)
import { assign, raise, sendTo, enqueueActions, and, or, not, stateIn, assertEvent } from "xstate";
// assign — object or function form; context is immutable, only assign mutates it
assign({ count: ({ context }) => context.count + 1 });
assign(({ context, event }) => ({ count: context.count + 1 }));
// raise — send an event to SELF (optionally delayed)
raise({ type: "RETRY" }, { delay: 1000 });
// sendTo — send to another actor by id, ref, or resolver
sendTo("childId", { type: "PING" });
sendTo(({ context }) => context.someRef, { type: "PING" });
// enqueueActions — replaces v4 choose/pure; imperative composition of actions
enqueueActions(({ context, enqueue, check }) => {
enqueue.assign({ count: context.count + 1 });
if (check({ type: "someGuard" })) enqueue("namedAction");
enqueue.sendTo("childId", { type: "GO" });
});
// guards — string ref, params, or higher-order combinators
guard: and(["isValid", or(["isAdmin", "isGuest"]), not("isBanned")]);
guard: stateIn({ form: "submitting" }); // replaces v4 `in:`
// assertEvent — narrow event type inside an action/guard (throws if wrong)
entry: ({ event }) => {
assertEvent(event, "inc");
console.log(event.by); // typed
};
Dynamic params make actions/guards reusable and decoupled from a specific machine — prefer them
over reading event directly:
const m = setup({
actions: { greet: (_, params: { name: string }) => console.log(`Hi ${params.name}`) },
}).createMachine({
entry: { type: "greet", params: ({ context }) => ({ name: context.user.name }) },
});
TypeScript helpers
import type { ActorRefFrom, SnapshotFrom, EventFromLogic } from "xstate";
type Ref = ActorRefFrom<typeof machine>; // strongly-typed actor reference (props, child refs)
type Snap = SnapshotFrom<typeof machine>; // typed snapshot
type Ev = EventFromLogic<typeof machine>; // union of all event types
Also available: ContextFrom, InputFrom, OutputFrom. Use assertEvent instead of casting to
narrow event payloads — the repo bans as casts.
@xstate/react (summary)
import { useMachine, useActor, useActorRef, useSelector } from "@xstate/react";
const [snapshot, send] = useMachine(machine); // [snapshot, send, actorRef]
const [snapshot2, send2] = useActor(machine); // useMachine is an alias of useActor
const actorRef = useActorRef(machine); // static ref, no re-render on change
const count = useSelector(actorRef, (s) => s.context.count); // re-renders only when count changes
Provide per-instance implementations via machine.provide(...); share an actor across a tree with
createActorContext(machine) → { Provider, useSelector, useActorRef }. As of @xstate/react 6.1.0,
useActor/useSelector throw when the actor errors (caught by the nearest error boundary). Full
patterns (composition, createActorContext, persisted state, child→parent comms) in
references/react-integration.md.
@xstate/store (summary)
A lightweight, type-safe, event-based store for when you don't need a full statechart.
import { createStore } from "@xstate/store";
const store = createStore({
context: { count: 0 },
on: {
// v3+ assigners return the WHOLE new context (spread it), or undefined to disallow
inc: (context, event: { by: number }) => ({ ...context, count: context.count + event.by }),
},
});
store.trigger.inc({ by: 1 }); // typed sugar for store.send({ type: "inc", by: 1 })
store.getSnapshot().context.count; // 1
import { useSelector } from "@xstate/store-react"; // NOT "@xstate/store/react" in v4
function Counter() {
const count = useSelector(store, (s) => s.context.count);
return <button onClick={() => store.trigger.inc({ by: 1 })}>{count}</button>;
}
See references/xstate-store.md for persist, atoms, schema validation, Immer, undo/redo, and
fromStore interop.
⚠️ Monorepo Gotchas (read before writing a machine here)
1. setup({ types }) vs the no-type-assertions rule
The official docs write setup({ types: { context: {} as Ctx, events: {} as Ev } }). The as casts are
banned by this repo's custom-rules/no-type-assertions ESLint rule — and you cannot just suppress
them with an inline lint-disable directive, because the pre-commit quality-ratchet caps the total
number of allowed suppressions repo-wide, so adding one more fails the commit.
Fix: hoist the phantom types into a single explicitly-annotated holder variable. The annotation (not the literal values) drives XState's inference, so the full event union is preserved:
import { setup } from "xstate";
interface PlaybackContext { count: number }
type PlaybackEvent = { type: "PLAY" } | { type: "SKIP"; n: number } | { type: "STOP" };
interface PlaybackInput { start: number }
// ✅ ONE annotated object variable — annotation carries the union; values are throwaway defaults.
const machineTypes: {
context: PlaybackContext;
events: PlaybackEvent;
input: PlaybackInput;
} = {
context: { count: 0 },
events: { type: "SKIP", n: 1 }, // one member is fine; the annotation supplies the whole union
input: { start: 0 },
};
export const playbackMachine = setup({
types: machineTypes,
// actors, guards, actions...
}).createMachine({
context: ({ input }) => ({ count: input.start }),
// ...
});
Pitfall: passing phantoms inline (types: { events: phantomEvent }) or via separate
per-field annotated consts does NOT work — XState's const-generic inference re-narrows events to
the single literal ({ type: "SKIP" }), which breaks actor.send and collapses state values to
never. The single annotated object variable is what locks it.
2. Promise actors — annotate the param, don't use a void generic
import { fromPromise } from "xstate";
// ✅ annotate the destructured param; void return is inferred cleanly
fromPromise(({ input, signal }: { input: { id: string }; signal: AbortSignal }) =>
fetch(`/api/x/${input.id}`, { signal }).then((r) => r.json()),
);
// ❌ fromPromise<void, TInput>(...) trips @typescript-eslint/no-invalid-void-type
3. Repo conventions
- Bun only —
bun add xstate, never npm/yarn/pnpm. Test withbun:test. - No
ascasts anywhere exceptas const/as unknown— useassertEvent, guards, and the holder-variable pattern instead of casting. - Keep examples
strict-clean; the base ESLint config appliesstrictTypeChecked.
Best Practices
setup()first, then.createMachine()— strongly type context/events/input once; reference actors/guards/actions by name. Use the holder-variable workaround in this repo.- Eliminate boolean soup — model mutually-exclusive modes as finite states, not
isLoading/isError/isSuccessflags. Illegal combinations become unrepresentable. - Invoke vs spawn —
invokefor a finite, state-bound async task (lifecycle tied to the state);spawnfor a dynamic/unknown number of actors (one per list item). See the actors reference. - Read with
useSelector— select the slice you need so components re-render only when it changes, instead of destructuring a wholeuseActorsnapshot. - Dynamic params in actions/guards — keep them reusable and decoupled from one machine.
- Keep actors small & composed — child refs live in the parent snapshot's
children; wrap with custom hooks to keep components decoupled. - Model-based testing —
createTestModel(machine)fromxstate/graphauto-generates paths covering every reachable transition;filterEventslimits to currently-enabled events. - Reach for
@xstate/storewhen you want event-based shared state without a full statechart — don't over-engineer simple global state into a machine.
When to Ask for Help
- Whether a flow genuinely needs a statechart vs
@xstate/storevs Zustand/Jotai/useReducer. - Designing hierarchy/parallelism for an ambiguous domain (many interacting modes).
- Performance at scale (many spawned actors, frequent re-renders) and persistence/rehydration strategy.
- Migrating a v4 machine — see the v4→v5 cheatsheet in
references/testing-and-migration.md.