xstate-v5 (Strict) Skill
Build, refactor, and review XState v5 state machines (TypeScript and React) using a strict, strongly-typed ruleset.
Source of truth: references/xstate-v5-rules.md (this repo, resolved from the skill root). If anything in this skill conflicts with the rules file, follow the rules file.
Inlined Non-Negotiable Contract (Agent Must Follow)
These requirements are intentionally duplicated here so an agent can comply without first loading external references:
- Design first, code second:
- Produce planning artifacts before implementation (state inventory, event catalog, transition table, async/actor map, acceptance-test scenarios). - Include a boundary/decomposition decision record (what is split vs orchestrated, and why).
- No god-machine architecture:
- Unrelated domains (auth, toasts, navigation, workflows, transport/retry policy) must not be fully modeled in one machine. - A single machine is acceptable only as a thin orchestration root or tightly coupled app-shell parallel regions.
- No state-mirroring context flags:
- Do not duplicate mode in context booleans (isLoading, isAuthenticated, etc.) when state.value already represents the mode. - If a temporary migration flag exists, document rationale and removal plan.
- XState v5 strictness:
- Use setup({...}).createMachine({...}). - Implement actions/guards/actors in setup(...). - Send event objects only. - Avoid forbidden v4 patterns (interpret, Machine, cond, send, pure, choose, etc.).
- Enforce explicit async and actor boundaries:
- Request/response: invoke with onDone and onError. - Long-lived collaborators: spawnChild/stopChild and explicit routing via sendTo.
Fail-closed rule:
- If any detail is ambiguous, load
references/xstate-v5-rules.mdbefore writing or reviewing machine code. - If that file is missing/unreadable, stop and report the issue instead of guessing.
When To Use
Use this skill when the task involves:
- XState v5 machines/actors (
xstate,@xstate/react) - Refactoring XState code to be more type-safe and idiomatic v5
- Migrating XState v4 patterns (Machine/interpret/cond/send/pure/choose/etc.) to v5 equivalents
- Planning/designing statecharts prior to implementing them as XState v5 machines
- Designing actor boundaries (invoke vs spawnChild) and React integration via
createActorContext
Hard Requirements (Enforced)
- Prefer
setup({...}).createMachine({...})for all machines. - Implement all
actions,guards, andactorsin thesetup({...})object. - Prefer passing event-derived data via typed
paramsto actions/guards. - Never send string events. Always send event objects:
actor.send({type: '...'}). - Forbid XState v4 legacy APIs and patterns listed in
references/xstate-v5-rules.md. - Do not model unrelated domains (auth, notifications/toasts, navigation, data lifecycle, etc.) inside one machine, except a thin orchestration machine.
- Do not mirror state in context booleans (
isLoading,isAuthenticated, etc.) whenstate.valuealready represents that mode.
Rules Table Of Contents
- Statechart Design: 0 Statechart Design (Before You Code)
- Core Principles: 1 Machine Creation Pattern, 2 Type Safety First
- Setup Object Rules: 3 Implementation Placement, 4 Parameter Typing
- Event Handling Rules: 5 Event Type Safety with assertEvent, 6 Event Object Requirement
- Deprecated Pattern Prevention: 7 Forbidden v4 Patterns, 8 Modern v5 Equivalents
- Context and Input Rules: 9 Context Initialization, 10 Context Updates
- Invoke and Actor Rules: 11 Invoke Configuration, 12 Actor Spawning
- Type Helper Rules: 13 Type Helpers
- Testing Rules: 14 Type-Safe Testing (14.1 Deterministic Actor Tests, 14.2 Model-Based Testing, 14.3 Graph Utilities)
- XState React Rules: 15 Shared State via createActorContext
- Best Practices Summary: 16 Code Organization, 17 Performance Considerations, 18 Error Handling, 19 Documentation
- Enforcement Rules for AI Agents: 20 Mandatory Patterns, 21 Quality Assurance, 22 When Docs Are Ambiguous
Quick Start
Minimal typed machine skeleton:
import { setup, assign } from "xstate"
type Ctx = { count: number }
type Ev = { type: "inc" } | { type: "add"; amount: number } | { type: "reset" }
type Input = { initialCount?: number }
export const counterMachine = setup({
types: {
context: {} as Ctx,
events: {} as Ev,
input: {} as Input,
},
actions: {
inc: assign({ count: ({ context }) => context.count + 1 }),
add: assign({
count: ({ context }, params: { amount: number }) => context.count + params.amount,
}),
reset: assign({ count: ({ input }) => input.initialCount ?? 0 }),
},
}).createMachine({
id: "counter",
context: ({ input }) => ({ count: input.initialCount ?? 0 }),
on: {
inc: { actions: "inc" },
add: {
actions: {
type: "add",
params: ({ event }) => ({ amount: event.amount }),
},
},
reset: { actions: "reset" },
},
})React shared state skeleton:
import { createActorContext, shallowEqual } from "@xstate/react"
import { type SnapshotFrom } from "xstate"
import { counterMachine } from "./counterMachine"
const CounterCtx = createActorContext(counterMachine)
export const CounterProvider = CounterCtx.Provider
export const useCounterSelector = CounterCtx.useSelector
export const useCounterActorRef = CounterCtx.useActorRef
const selectCount = (s: SnapshotFrom<typeof counterMachine>) => s.context.count
export const useCount = () => useCounterSelector(selectCount)
const selectCtx = (s: SnapshotFrom<typeof counterMachine>) => s.context
export const useCounterContext = () => useCounterSelector(selectCtx, shallowEqual)Workflow (What To Do Each Time)
- Read
references/xstate-v5-rules.mdfirst. - Identify the target surface area:
- Plain machine/actor (non-React) - React integration (createActorContext) or local useActor - Migration from v4 patterns
- Design the statechart first:
- See references/xstate-v5-rules.md#0-statechart-design-before-you-code. - Write down: state inventory, event catalog (payload + source), transition table, async/actor boundaries, and acceptance-test scenarios. - Record a boundary/decomposition decision before type design: - Which concerns belong in separate actors/machines (based on lifecycle, owner, and failure mode). - Which concerns are intentionally orchestrated at top-level (and why). - Any exception note if keeping logic in one machine. - Default: do this unless the user explicitly asks to skip planning.
- Design types first:
- types.context, types.events, and types.input (and types.output if applicable) - Use zod schemas only if the codebase already uses Zod or the user asks for it.
- Put implementations in
setup({actions, guards, actors}):
- Actions/guards take typed params when they need event-derived data. - Only read event-specific fields inside implementations when necessary; use assertEvent then.
- Choose async boundaries:
- Prefer invoke with typed input for request/response flows. - Use spawnChild/stopChild for long-lived child actors.
- Use v5 runtime APIs:
- createActor(machine) (not interpret) - raise / sendTo (not send action) - enqueueActions (not pure/choose)
- Validate:
- tsc passes; no any leaks in params. - No string event sends. - No v4 forbidden imports or config keys (cond, withContext, withConfig, etc.). - Prefer waitFor for async actor tests and xstate/graph (createTestModel, getShortestPaths, etc.) for model/graph-driven test generation.
When A Monolith Is Acceptable
Use a single top-level machine only when it is a thin orchestration root that coordinates child actors or tightly coupled parallel app-shell concerns. Even then, unrelated domain logic should live in separate actors/machines and communicate via explicit events.
Review Checklist (Use When Auditing PRs)
- All machines are
setup(...).createMachine(...). - Machine has a clear statechart plan (states/events/transition table) or the PR description includes it.
- No domain-smell: one machine has one responsibility or explicit orchestration scope.
- No state-mirroring context flags without documented rationale.
- No implementations inside
types. actions/guardsread event fields only viaparamsorassertEvent.- No
interpret,Machine,withConfig,withContext,cond,send,pure,choose. invokehasonError(andonDonewhen appropriate).- React usage prefers
createActorContextat module scope; selectors are stable and useshallowEqualfor objects.
Common Fixes
- Event payload used inside an action:
- Prefer: compute payload in params and make the action implementation depend on params. - Otherwise: add assertEvent(event, 'someType') inside the implementation.
- v4
cond:
- Rename to guard and move guard implementation into setup({guards: {...}}).
- v4
interpret:
- Replace with createActor(machine) and .start() where needed.
Notes
If official docs feel ambiguous, follow the rules file guidance: confirm behavior against XState v5 source, issues/discussions, or a minimal reproduction/type-test rather than guessing.