Fix: XState Not Working — Machine Not Transitioning, Guards Not Running, or Actor Not Sending Events
Quick Answer
How to fix XState v5 issues — state machine definition, guards and actions typed correctly, useMachine hook, createActor, context updates, child actors, and common v4 to v5 migration errors.
The Problem
A state machine transitions to the wrong state or not at all:
const machine = createMachine({
initial: 'idle',
states: {
idle: {
on: { FETCH: 'loading' },
},
loading: {
// Transitions to idle instead of success or error
},
},
});Or guards aren’t preventing transitions they should block:
const machine = createMachine({
states: {
form: {
on: {
SUBMIT: {
guard: 'isValid',
target: 'submitting',
},
},
},
},
});
// Guard 'isValid' never runs — machine transitions unconditionallyOr in React, the component doesn’t re-render when the machine transitions:
const [state, send] = useMachine(machine);
send({ type: 'FETCH' });
// state.value is still 'idle' — component didn't updateOr the XState v4 API throws errors after upgrading to v5:
// v4 syntax
import { createMachine, assign } from 'xstate';
const machine = createMachine({
schema: { context: {} as MyContext }, // Error: unknown property 'schema'
});Why This Happens
XState enforces strict patterns for state machines:
- Guards and actions must be provided to the actor — defining
guard: 'isValid'in the machine configuration is a reference to a named guard. If the guard function isn’t provided when creating the actor (viacreateActororuseMachine), XState either throws or silently skips the guard. - v5 broke most of the v4 API — XState v5 renamed
servicestoactors, removedschema, changedassignusage, mergedinterpretintocreateActor, and changed how guards/actions are typed and provided. useMachinerequires the@xstate/reactpackage — XState core and the React integration are separate packages. UsinguseMachinefromxstate(not@xstate/react) fails.- Machines are immutable — you can’t modify a machine after creation. State and context changes only happen via transitions and assigned actions.
Fix 1: Define a Machine Correctly in v5
import { createMachine, assign, fromPromise } from 'xstate';
interface FetchContext {
users: User[];
error: string | null;
retries: number;
}
type FetchEvent =
| { type: 'FETCH' }
| { type: 'RETRY' }
| { type: 'RESET' };
const fetchMachine = createMachine(
{
id: 'fetch',
initial: 'idle',
// Context — initial values
context: {
users: [],
error: null,
retries: 0,
} satisfies FetchContext,
// Types are inferred, but you can be explicit
types: {} as {
context: FetchContext;
events: FetchEvent;
},
states: {
idle: {
on: {
FETCH: 'loading',
},
},
loading: {
// Invoke an async service
invoke: {
id: 'fetchUsers',
src: 'fetchUsers', // References provided actor
onDone: {
target: 'success',
actions: assign({
users: ({ event }) => event.output, // v5: use event.output
error: null,
}),
},
onError: {
target: 'failure',
actions: assign({
error: ({ event }) => String(event.error),
}),
},
},
},
success: {
on: {
FETCH: 'loading', // Allow re-fetching
RESET: {
target: 'idle',
actions: assign({ users: [], error: null }),
},
},
},
failure: {
on: {
RETRY: {
target: 'loading',
guard: 'canRetry',
actions: assign({
retries: ({ context }) => context.retries + 1,
}),
},
RESET: {
target: 'idle',
actions: assign({ users: [], error: null, retries: 0 }),
},
},
},
},
},
{
// Provide named guards and actors
guards: {
canRetry: ({ context }) => context.retries < 3,
},
actors: {
fetchUsers: fromPromise(async () => {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<User[]>;
}),
},
}
);Fix 2: Use useMachine in React
// Install: npm install @xstate/react
import { useMachine } from '@xstate/react';
function UserList() {
const [state, send] = useMachine(fetchMachine);
return (
<div>
{state.matches('idle') && (
<button onClick={() => send({ type: 'FETCH' })}>
Load Users
</button>
)}
{state.matches('loading') && <Spinner />}
{state.matches('success') && (
<>
<ul>
{state.context.users.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
<button onClick={() => send({ type: 'FETCH' })}>
Refresh
</button>
</>
)}
{state.matches('failure') && (
<>
<p>Error: {state.context.error}</p>
<button onClick={() => send({ type: 'RETRY' })}>
Retry ({3 - state.context.retries} remaining)
</button>
</>
)}
</div>
);
}Override machine configuration with useMachine options:
const [state, send] = useMachine(fetchMachine, {
// Override or add guards/actors for this component instance
guards: {
canRetry: ({ context }) => context.retries < 5, // Allow 5 retries
},
actors: {
fetchUsers: fromPromise(async () => fetchUsersFromApi()),
},
// Set initial context
input: { userId: currentUserId },
});Subscribe to state changes with useSelector:
import { useSelector } from '@xstate/react';
// Avoid re-renders by selecting only needed state
function UserCount({ actorRef }) {
const count = useSelector(actorRef, state => state.context.users.length);
return <span>{count} users</span>;
}
// Access the actor ref from parent
const [state, send, actorRef] = useMachine(fetchMachine);
// Pass actorRef to child:
<UserCount actorRef={actorRef} />Fix 3: Work with Guards and Actions
Guards determine whether a transition happens. Actions are side effects that run during transitions:
const formMachine = createMachine(
{
id: 'form',
initial: 'editing',
context: {
name: '',
email: '',
submitted: false,
},
states: {
editing: {
on: {
UPDATE: {
actions: 'updateField',
},
SUBMIT: [
// Multiple transitions with guards — first match wins
{
guard: 'isEmailValid',
guard2: 'isNameFilled', // Multiple guards on same transition
target: 'submitting',
},
// Fallback — no guard = always matches
{
actions: 'showValidationErrors',
},
],
},
},
submitting: {
invoke: {
src: 'submitForm',
onDone: 'success',
onError: {
target: 'editing',
actions: 'setError',
},
},
},
success: { type: 'final' },
},
},
{
guards: {
isEmailValid: ({ context }) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(context.email),
isNameFilled: ({ context }) => context.name.trim().length > 0,
},
actions: {
updateField: assign({
// v5: action receives event directly
name: ({ context, event }) =>
event.type === 'UPDATE' && event.field === 'name'
? event.value
: context.name,
email: ({ context, event }) =>
event.type === 'UPDATE' && event.field === 'email'
? event.value
: context.email,
}),
showValidationErrors: () => {
console.log('Validation failed');
},
setError: assign({
error: ({ event }) => String(event.error),
}),
},
}
);Combining guards (v5 and/or/not helpers):
import { and, or, not } from 'xstate';
const machine = createMachine({
states: {
form: {
on: {
SUBMIT: {
guard: and(['isEmailValid', 'isNameFilled', not('isSubmitting')]),
target: 'submitting',
},
DELETE: {
guard: or(['isAdmin', 'isOwner']),
target: 'deleting',
},
},
},
},
});Fix 4: Use createActor Outside React
For logic outside React components (services, utilities, tests):
import { createActor } from 'xstate';
// Create and start an actor
const actor = createActor(fetchMachine);
actor.start();
// Subscribe to state changes
actor.subscribe((state) => {
console.log('State:', state.value);
console.log('Context:', state.context);
});
// Send events
actor.send({ type: 'FETCH' });
// Get current snapshot
const snapshot = actor.getSnapshot();
console.log(snapshot.value); // Current state
// Stop the actor when done
actor.stop();
// Wait for the actor to reach a specific state
const finalState = await waitFor(
actor,
(state) => state.matches('success') || state.matches('failure'),
{ timeout: 10_000 }
);Actor communication — parent spawning children:
import { createMachine, assign, sendTo, spawnChild, stopChild } from 'xstate';
const parentMachine = createMachine({
context: {
childRef: null as ActorRef<typeof childMachine> | null,
},
states: {
active: {
entry: assign({
childRef: ({ spawn }) => spawn(childMachine, { id: 'child' }),
}),
on: {
NOTIFY_CHILD: {
actions: sendTo('child', { type: 'NOTIFY' }),
},
STOP: {
actions: [
stopChild('child'),
assign({ childRef: null }),
],
target: 'idle',
},
},
},
},
});Fix 5: Migrate from XState v4 to v5
XState v5 is a significant rewrite. Key breaking changes:
// V4 → V5 migration guide
// 1. interpret() → createActor()
// v4:
import { interpret } from 'xstate';
const service = interpret(machine).start();
// v5:
import { createActor } from 'xstate';
const actor = createActor(machine).start();
// 2. Schema removed — use types
// v4:
createMachine({
schema: { context: {} as MyContext, events: {} as MyEvent },
});
// v5:
createMachine({
types: {} as { context: MyContext; events: MyEvent },
});
// 3. assign() — event access changed
// v4:
assign({ count: (context, event) => context.count + event.amount })
// v5:
assign({ count: ({ context, event }) => context.count + event.amount })
// 4. Services → actors
// v4:
createMachine({ ... }, {
services: { fetchUsers: () => fetch('/api/users').then(r => r.json()) }
});
// v5:
createMachine({ ... }, {
actors: {
fetchUsers: fromPromise(() => fetch('/api/users').then(r => r.json()))
}
});
// 5. onDone event data → event.output
// v4: event.data
// v5: event.output
onDone: {
actions: assign({ result: ({ event }) => event.output })
}
// 6. send() inside machines
// v4:
actions: send('CHILD_EVENT', { to: 'childId' })
// v5:
actions: sendTo('childId', { type: 'CHILD_EVENT' })Fix 6: Test State Machines
import { createActor, waitFor } from 'xstate';
import { describe, test, expect } from 'vitest';
describe('fetchMachine', () => {
test('transitions from idle to loading on FETCH', () => {
const actor = createActor(fetchMachine).start();
expect(actor.getSnapshot().value).toBe('idle');
actor.send({ type: 'FETCH' });
expect(actor.getSnapshot().value).toBe('loading');
actor.stop();
});
test('reaches success state after successful fetch', async () => {
// Override the fetchUsers actor with a mock
const actor = createActor(fetchMachine.provide({
actors: {
fetchUsers: fromPromise(async () => [
{ id: 1, name: 'Alice' },
]),
},
})).start();
actor.send({ type: 'FETCH' });
const finalState = await waitFor(
actor,
state => state.matches('success'),
{ timeout: 1000 }
);
expect(finalState.context.users).toHaveLength(1);
expect(finalState.context.users[0].name).toBe('Alice');
actor.stop();
});
test('guard prevents retry after 3 attempts', () => {
const actor = createActor(fetchMachine.provide({
context: { users: [], error: 'Error', retries: 3 },
actors: {
fetchUsers: fromPromise(async () => { throw new Error('fail'); }),
},
})).start();
// Force to failure state
actor.send({ type: 'FETCH' });
// Wait for failure...
// Guard should block this transition
actor.send({ type: 'RETRY' });
expect(actor.getSnapshot().value).toBe('failure'); // Didn't transition
actor.stop();
});
});Still Not Working?
Machine never leaves the initial state after send() — verify the event type matches exactly. XState uses string comparison for event types. send({ type: 'fetch' }) won’t match on: { FETCH: ... }. Types are case-sensitive.
state.matches() returns false for a nested state — for parallel or nested states, use state.matches({ parent: 'child' }) or state.matches('parent.child'):
// Nested state
state.matches({ form: 'editing' }) // parent: child
state.matches('form.editing') // dot notation
// Parallel states
state.matches({ form: 'editing', network: 'online' })Context not updating after assign — in XState v5, assign only updates context via transitions. You can’t call assign outside of a machine action, and assign doesn’t mutate the existing context object — it produces a new snapshot. If you’re logging state.context after send() synchronously, you may be reading the old snapshot. Access the new context via actor.getSnapshot().context or in the subscribe callback.
For related state management patterns, see Fix: Zustand Not Working and Fix: React useState Not Updating.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: CodeMirror Not Working — Editor Not Rendering, Extensions Not Loading, or React State Out of Sync
How to fix CodeMirror 6 issues — basic setup, language and theme extensions, React integration, vim mode, collaborative editing, custom keybindings, and read-only mode.
Fix: GSAP Not Working — Animations Not Playing, ScrollTrigger Not Firing, or React Cleanup Issues
How to fix GSAP animation issues — timeline and tween basics, ScrollTrigger setup, React useGSAP hook, cleanup and context, SplitText, stagger animations, and Next.js integration.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: ky Not Working — Requests Failing, Hooks Not Firing, or Retry Not Working
How to fix ky HTTP client issues — instance creation, hooks (beforeRequest, afterResponse), retry configuration, timeout handling, JSON parsing, error handling, and migration from fetch or axios.