Claude Code with XState: Finite State Machines Without the Spaghetti
Why XState without CLAUDE.md ships state machines stuck in v4 syntax
XState v5 reshaped the library. The Machine constructor became setup() plus createMachine(). The interpret() function became createActor(). The actor model moved from optional to central. The TypeScript inference got stronger, the runtime got smaller, and the public API got cleaner. The footgun is that Claude Code's training data is dense with v4 examples: Machine({ ... }), interpret(machine).start(), withContext(), context: () => initial, the legacy services map. Run the v4 syntax against v5 and the code compiles in some shapes and breaks in others, the inference is wrong, and the runtime warnings are subtle enough that the bug ships.
Claude Code does not know any of this by default. It generates const machine = Machine({ id: 'auth', initial: 'idle', states: {...} }), ships a React component that calls useMachine(machine), and stops. The result might run if the dependencies happen to be v5-compatible, and the moment the engineer adds an invoke for an async task, the syntax drift becomes a runtime error. The most common Claude defaults that break XState in production: machines created with Machine() instead of setup().createMachine() so the strong-typing of actions and guards is lost, services invoked with v4's services config instead of v5's invoke.src with an actor reference, actors spawned without proper cleanup so the parent machine retains references to dead children, machines re-created on every render so the actor identity changes and subscribers miss updates, and selectors that read from the snapshot without typing the inferred state so the consumer ends up with any.
This guide covers the CLAUDE.md configuration that locks Claude Code into XState v5: machines defined with setup() for explicit action/guard typing, actors created with createActor() and held in refs, the React hooks shape with useMachine() for self-contained machines and useActor() for externally-managed actors, async work done through invoked actors with explicit input and output schemas, and the actor cleanup contract enforced via useEffect unmount. For the React patterns the integration depends on, Claude Code with React covers the hooks model that pairs cleanly with XState's actor system.
The XState CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For an XState v5 project it needs to declare: the package version, the machine authoring rules, the actor model usage, the React integration shape, the type-safety patterns, and the hard rules.
# XState rules
## Stack
- xstate ^5.x (NEVER v4 syntax, the API is different)
- @xstate/react ^4.x for React hooks
- @xstate/store for lightweight reactive stores (optional)
- TypeScript 5.0+ for setup() inference
## Project structure
- src/machines/ , one folder per state machine
- src/machines/<name>/ , machine definition + actors + selectors + types
- src/machines/<name>/machine.ts , setup().createMachine() definition
- src/machines/<name>/actors.ts , invoked actor logic (fromPromise, fromCallback, etc.)
- src/machines/<name>/selectors.ts , typed selectors for consumers
- src/machines/<name>/types.ts , Context, Event, Input types
## Machine authoring (MANDATORY)
- Use setup({ types, actions, guards, actors }) FIRST
- Then call .createMachine({ ... }) on the setup result
- Types declared via setup({ types: {} as { context: ..., events: ... } })
- Actions and guards declared in setup() and referenced by name in transitions
- NEVER use the v4 Machine() function
## Actors (MANDATORY)
- Async work via invoked actors using fromPromise / fromCallback / fromObservable
- Spawned actors via spawnChild action with explicit cleanup on unmount/done
- Actor input via input: ({ context }) => ({...}), output via output transitions
- Parent-child communication via sendTo() and sendParent()
## React integration (MANDATORY)
- Self-contained machine: const [snapshot, send] = useMachine(machine)
- Externally-managed actor: const actor = useActor(externalActor)
- ALWAYS pass typed selectors via useSelector(actor, (s) => s.matches('...'))
- NEVER store the actor in useState (use useRef or a context provider)
## Type safety
- Context typed via setup({ types: {} as { context: ContextType } })
- Events typed via setup({ types: {} as { events: EventType } })
- Input typed via setup({ types: {} as { input: InputType } })
- Selectors use the inferred snapshot type from the actor
- NEVER use `any` in selectors or actor inputs
## Hard rules
- NEVER use v4 syntax (Machine, interpret, services map)
- NEVER recreate the machine on every render
- NEVER skip actor cleanup (stop() on unmount or via useMachine's lifecycle)
- NEVER reference actions or guards as inline functions in transitions (use setup())
- NEVER spawn actors without saving the reference for cleanup
- ALWAYS use setup() before createMachine()
- ALWAYS type events and context via setup({ types: {...} })
Five rules here prevent the majority of incidents Claude generates without them.
The v5 syntax rule prevents the version drift. setup({ ... }).createMachine({ ... }) is the v5 idiom and gives the strong typing that v4 lacked. The v4 Machine({ ... }) function still exists in some forks but does not give the same inference and does not integrate with v5's actor system. The rule "Use setup() FIRST" forces the new API by name.
The actor model rule prevents the runaway-effect bug. XState v5 treats every running machine as an actor with a lifecycle. Invoked actors are children of the machine that invoked them and stop when the parent transitions out of the invoking state. Spawned actors are children too but live until explicitly stopped. Async work outside this model (a setTimeout in an action, a fetch in an entry callback) leaks if the machine transitions before the work finishes. The rule "Async work via invoked actors" forces the work inside the lifecycle.
The React integration rule prevents the actor-identity-changes bug. useMachine is for machines the component owns and creates. useActor is for actors created elsewhere (a context provider, a parent component, a top-level service registry) and passed down. Mixing them breaks. The rule makes the choice explicit per component.
The type safety rule prevents the any regression. v5's setup({ types: {...} }) declares the context, event, and input types in one place, which propagates through the entire machine definition and into the React hooks. Without it, the snapshot is typed as the union of all possible states, which is correct but not useful, and selectors fall through to any. The rule forces the explicit type declaration.
The machine recreation rule prevents the subscriber-stale-data bug. A machine recreated on every render produces a new actor with a new ID, which useMachine interprets as a new machine that needs a fresh start, which discards the previous state. Worse, if the machine is recreated inside a parent component, any child that holds a stale ref points at a dead actor. The rule "NEVER recreate the machine on every render" forces module-scope machine definitions.
Install and a minimal machine
Install XState v5 and the React bindings:
pnpm add xstate @xstate/react
A complete auth machine using setup():
// src/machines/auth/types.ts
export type AuthContext = {
email: string;
password: string;
user: { id: string; email: string } | null;
error: string | null;
};
export type AuthEvent =
| { type: 'EMAIL'; value: string }
| { type: 'PASSWORD'; value: string }
| { type: 'SUBMIT' }
| { type: 'RETRY' }
| { type: 'LOGOUT' };
// src/machines/auth/actors.ts
import { fromPromise } from 'xstate';
type LoginInput = { email: string; password: string };
type LoginOutput = { id: string; email: string };
export const loginActor = fromPromise<LoginOutput, LoginInput>(
async ({ input }) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message ?? 'Login failed');
}
return (await res.json()) as LoginOutput;
},
);
// src/machines/auth/machine.ts
import { assign, setup } from 'xstate';
import type { AuthContext, AuthEvent } from './types';
import { loginActor } from './actors';
export const authMachine = setup({
types: {
context: {} as AuthContext,
events: {} as AuthEvent,
},
actors: {
login: loginActor,
},
actions: {
setEmail: assign({
email: ({ event }) => (event.type === 'EMAIL' ? event.value : ''),
}),
setPassword: assign({
password: ({ event }) => (event.type === 'PASSWORD' ? event.value : ''),
}),
setUser: assign({
user: ({ event }) => (event as any).output,
error: null,
}),
setError: assign({
error: ({ event }) => (event as any).error?.message ?? 'Unknown error',
}),
clearAll: assign({
email: '',
password: '',
user: null,
error: null,
}),
},
guards: {
hasCredentials: ({ context }) =>
context.email.length > 0 && context.password.length > 0,
},
}).createMachine({
id: 'auth',
initial: 'idle',
context: {
email: '',
password: '',
user: null,
error: null,
},
states: {
idle: {
on: {
EMAIL: { actions: 'setEmail' },
PASSWORD: { actions: 'setPassword' },
SUBMIT: {
target: 'submitting',
guard: 'hasCredentials',
},
},
},
submitting: {
invoke: {
src: 'login',
input: ({ context }) => ({
email: context.email,
password: context.password,
}),
onDone: {
target: 'authenticated',
actions: 'setUser',
},
onError: {
target: 'failed',
actions: 'setError',
},
},
},
authenticated: {
on: {
LOGOUT: {
target: 'idle',
actions: 'clearAll',
},
},
},
failed: {
on: {
RETRY: 'idle',
},
},
},
});
Five things in this machine that the CLAUDE.md rule enforces. setup() is called first with the type declarations, the named actors, the named actions, and the named guards. The createMachine() call on the setup result is where the state graph lives, and every reference inside it (action names, guard names, actor names) is type-checked against the setup. The invoke block uses src: 'login' referencing the registered actor, with input derived from context, onDone consuming the actor's output, and onError catching rejections. The assign actions are typed because the context parameter is the declared AuthContext. The whole machine is a constant at module scope, so React's render cycle does not recreate it.
The Login actor is a fromPromise actor with explicit input and output types. The input parameter is destructured from the actor invocation, and the return value becomes the event.output on onDone. This is the v5 actor contract.
React integration with useMachine and selectors
The React bindings give two hooks for consuming machines.
For a machine that lives inside a single component:
// src/components/LoginForm.tsx
'use client';
import { useMachine } from '@xstate/react';
import { authMachine } from '../machines/auth/machine';
export function LoginForm() {
const [snapshot, send] = useMachine(authMachine);
if (snapshot.matches('authenticated')) {
return (
<div>
<p>Welcome back, {snapshot.context.user?.email}</p>
<button type="button" onClick={() => send({ type: 'LOGOUT' })}>
Log out
</button>
</div>
);
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
send({ type: 'SUBMIT' });
}}
>
<input
type="email"
value={snapshot.context.email}
onChange={(e) => send({ type: 'EMAIL', value: e.target.value })}
/>
<input
type="password"
value={snapshot.context.password}
onChange={(e) => send({ type: 'PASSWORD', value: e.target.value })}
/>
<button
type="submit"
disabled={snapshot.matches('submitting')}
>
{snapshot.matches('submitting') ? 'Signing in' : 'Sign in'}
</button>
{snapshot.matches('failed') && (
<p role="alert">{snapshot.context.error}</p>
)}
</form>
);
}
Three things in this component that the CLAUDE.md rule enforces. The machine is imported by reference from its module, not recreated inline. The snapshot is read via snapshot.matches('state') rather than snapshot.value === 'state', which gives correct nested-state semantics. The events sent via send() are typed against AuthEvent from the setup, so a typo in the event name fails compilation.
For a machine that lives at a higher level and is consumed by multiple components, the pattern is createActor() plus a context provider plus useSelector reads:
// src/machines/auth/provider.tsx
'use client';
import { createActor, type ActorRefFrom } from 'xstate';
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
import { authMachine } from './machine';
type AuthActor = ActorRefFrom<typeof authMachine>;
const AuthContext = createContext<AuthActor | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const actorRef = useRef<AuthActor | null>(null);
if (actorRef.current === null) {
actorRef.current = createActor(authMachine);
actorRef.current.start();
}
useEffect(() => {
return () => {
actorRef.current?.stop();
actorRef.current = null;
};
}, []);
return <AuthContext.Provider value={actorRef.current}>{children}</AuthContext.Provider>;
}
export function useAuthActor(): AuthActor {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuthActor must be used inside AuthProvider');
return ctx;
}
// src/components/Header.tsx
'use client';
import { useSelector } from '@xstate/react';
import { useAuthActor } from '../machines/auth/provider';
export function Header() {
const actor = useAuthActor();
const user = useSelector(actor, (s) => s.context.user);
const isSubmitting = useSelector(actor, (s) => s.matches('submitting'));
if (isSubmitting) return <header>Signing in</header>;
if (user) return <header>Logged in as {user.email}</header>;
return <header>Not signed in</header>;
}
Three patterns in this provider that the CLAUDE.md rule enforces. The actor is created lazily inside a useRef initialiser to avoid recreating it on every render. The useEffect unmount cleanup calls actor.stop(), which releases any invoked actors, cancels any pending fetches, and removes subscribers. The consumer components use useSelector with typed selectors, which only re-renders when the selected value changes.
Add a React integration rule to CLAUDE.md:
## React integration
- Single-component machine: useMachine(machine)
- Shared machine: createActor() inside useRef, provided via context
- useSelector(actor, (s) => ...) for typed reads
- Actor cleanup via useEffect unmount calling actor.stop()
- NEVER call useMachine with a machine created inline
For the form state layer that pairs with XState for input handling, Claude Code with React Hook Form covers the patterns that suit form-driven machines. For the data-fetching layer that competes with invoked actors for the same async-work surface, Claude Code with React Query covers the patterns and when to choose which.
Invoked actors vs spawned actors
XState v5 has two ways to create child actors. Invoked actors are tied to a parent state: they start on entry, stop on exit. Spawned actors are independent: the parent decides when to start and stop them.
Invoked actor (already shown above):
states: {
submitting: {
invoke: {
src: 'login',
input: ({ context }) => ({ ... }),
onDone: { ... },
onError: { ... },
},
},
}
Spawned actor for ongoing work:
// inside the machine
import { assign, sendTo, spawnChild } from 'xstate';
states: {
watchingNotifications: {
entry: assign({
notificationsRef: ({ spawn }) =>
spawn('notifications', { id: 'notifications' }),
}),
on: {
LOGOUT: {
target: 'idle',
actions: [
sendTo('notifications', { type: 'STOP' }),
assign({ notificationsRef: null }),
],
},
},
},
}
The decision between invoke and spawn: if the actor's lifetime matches a state's lifetime, use invoke. If the actor needs to outlive a state transition (e.g., a websocket connection used by multiple states), use spawn and manage the lifecycle explicitly.
Add an actor lifecycle rule to CLAUDE.md:
## Actor lifecycle
- Invoked actors: bind to state lifetime, automatic cleanup
- Spawned actors: parent manages, save ref in context, send STOP on cleanup
- NEVER fire-and-forget an async action outside the actor system
- onDone consumes output, onError catches rejections
Common Claude Code mistakes with XState
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. v4 Machine() syntax
Claude generates:
const machine = Machine({
id: 'auth',
initial: 'idle',
context: { ... },
states: { ... },
});
This is v4. In v5, Machine is not exported. The code fails to import.
Correct pattern: setup({...}).createMachine({...}).
2. Inline action functions in transitions
Claude generates:
SUBMIT: {
target: 'submitting',
actions: ({ context }) => fetchData(context),
}
The inline action loses the typing benefits of setup() and cannot be reused. Worse, fetching inside an action bypasses the actor system.
Correct pattern: register the actor in setup({ actors: {...} }) and invoke it from the state.
3. Machine recreated on every render
Claude generates:
function Component() {
const machine = createMachine({ ... });
const [state, send] = useMachine(machine);
return ...;
}
The machine identity changes every render, causing useMachine to discard state.
Correct pattern: define the machine at module scope and import it.
4. Async work outside the actor system
Claude generates:
entry: ({ context }) => {
fetch('/api/...').then(res => res.json()).then(data => {
// do something with data
});
}
The fetch leaks if the machine transitions before it resolves.
Correct pattern: invoke an actor that wraps the fetch via fromPromise.
5. Spawned actor without cleanup
Claude generates a spawn in entry but no STOP in any transition. The actor lives forever, consuming resources and potentially sending events to a parent that no longer cares.
Correct pattern: store the spawned actor ref in context, send STOP and clear the ref on cleanup transitions.
6. useSelector without typed selector
Claude generates const value = useSelector(actor, s => s.value). The selector returns any because the actor's snapshot type was not inferred.
Correct pattern: type the actor reference via ActorRefFrom<typeof machine>, and the selector inference flows through.
Add this list to CLAUDE.md with the corrected form for each.
Permission hooks for XState projects
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm add xstate*)",
"Bash(pnpm add @xstate/*)",
"Bash(pnpm test*)",
"Bash(pnpm tsc --noEmit*)",
"Bash(pnpm vitest*)"
],
"deny": [
"Bash(pnpm remove xstate*)",
"Bash(rm -rf src/machines*)"
]
}
}
Building state machines that survive every transition
The XState CLAUDE.md in this guide produces machines defined with setup() for strong typing of actions, guards, and actors, every async task running inside an invoked or spawned actor so the lifecycle is explicit, every React consumer using the right hook (useMachine for self-contained, useActor+useSelector for shared), every selector typed via ActorRefFrom for end-to-end inference, and every spawned actor cleaned up via an explicit STOP and context reset.
The underlying principle is the same as any state-management library with Claude Code. XState without a CLAUDE.md produces machines that look right and behave wrong: v4 syntax that fails to import in v5, async work that leaks past state transitions, machines recreated on every render that discard their state, selectors that fall through to any, and spawned actors that live forever in memory.
For the React patterns the integration depends on, Claude Code with React covers the hooks model that pairs cleanly with actors. For the data-fetching layer that competes for the same async surface as invoked actors, Claude Code with React Query covers when to pick which. Claudify includes an XState v5 CLAUDE.md template with the setup() patterns, the actor model rules, the React integration shapes, the type-safety contracts, and all six common-mistake rules pre-configured.
Get Claudify. Ship XState machines that handle every transition.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify