--- 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 | ```bash 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-agnostic `Readable` 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: {}` + explicit `id` is reachable from anywhere via `{ type: 'xstate.route', to: '#id' }`. Routes accept a `guard` (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 (default `Infinity`). - **`filterEvents`** (5.30) — `xstate/graph` + `createTestModel` traversal 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 typed `enqueueActions`/`emit`/`spawnChild`. - **`setup.extend()`** (5.24) and **`setup().createStateConfig()`** (5.21) — composable, modular setups. - **`mapState(snapshot, mapper)`** (5.31), **`getNextTransitions(snapshot)`** (5.26), partial descriptors in `assertEvent(event, 'FEEDBACK.*')` (5.25). - **Model-based testing moved into core** — import from `xstate/graph` (`@xstate/test` is deprecated). - **`@xstate/store` v3/v4** — `store.trigger.someEvent(...)` typed API, `.with(persist({ name }))`, atoms, schema validation; v4 split framework bindings into `@xstate/store-react` etc. ## 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`, and `fromTransition` are 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 — read `getSnapshot()` for that). ```ts 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. See `references/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**). ```ts 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) ```ts 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: ```ts 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 ```ts import type { ActorRefFrom, SnapshotFrom, EventFromLogic } from "xstate"; type Ref = ActorRefFrom; // strongly-typed actor reference (props, child refs) type Snap = SnapshotFrom; // typed snapshot type Ev = EventFromLogic; // 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) ```tsx 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. ```ts 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 ``` ```tsx import { useSelector } from "@xstate/store-react"; // NOT "@xstate/store/react" in v4 function Counter() { const count = useSelector(store, (s) => s.context.count); return ; } ``` 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: ```ts 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 ```ts 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(...) trips @typescript-eslint/no-invalid-void-type ``` ### 3. Repo conventions - **Bun only** — `bun add xstate`, never npm/yarn/pnpm. Run Vitest through Bun. - **No `as` casts** anywhere except `as const` / `as unknown` — use `assertEvent`, guards, and the holder-variable pattern instead of casting. - Keep examples `strict`-clean; the base ESLint config applies `strictTypeChecked`. ## Best Practices 1. **`setup()` first, then `.createMachine()`** — strongly type context/events/input once; reference actors/guards/actions by name. Use the holder-variable workaround in this repo. 2. **Eliminate boolean soup** — model mutually-exclusive modes as finite states, not `isLoading`/`isError`/`isSuccess` flags. Illegal combinations become unrepresentable. 3. **Invoke vs spawn** — `invoke` for a finite, state-bound async task (lifecycle tied to the state); `spawn` for a dynamic/unknown number of actors (one per list item). See the actors reference. 4. **Read with `useSelector`** — select the slice you need so components re-render only when it changes, instead of destructuring a whole `useActor` snapshot. 5. **Dynamic params** in actions/guards — keep them reusable and decoupled from one machine. 6. **Keep actors small & composed** — child refs live in the parent snapshot's `children`; wrap with custom hooks to keep components decoupled. 7. **Model-based testing** — `createTestModel(machine)` from `xstate/graph` auto-generates paths covering every reachable transition; `filterEvents` limits to currently-enabled events. 8. **Reach for `@xstate/store`** when 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/store` vs 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`.