xstate-helper

star 20

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.

shepherdjerred By shepherdjerred schedule Updated 6/7/2026

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-agnostic Readable<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: {} + 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/v4store.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).
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).

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 onlybun add xstate, never npm/yarn/pnpm. Test with bun:test.
  • 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 spawninvoke 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 testingcreateTestModel(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.
Install via CLI
npx skills add https://github.com/shepherdjerred/monorepo --skill xstate-helper
Repository Details
star Stars 20
call_split Forks 8
navigation Branch main
article Path SKILL.md
More from Creator
shepherdjerred
shepherdjerred Explore all skills →