Fix: XState Not Working — Machine Not Transitioning, Guards Not Running, or Actor Not Sending Events
Part of: React & Frontend Errors
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 makes state machines explicit, but the strictness that makes them valuable also makes the failure modes subtle.
The first source of confusion is the separation between machine definition and implementation. Inside createMachine({...}) you reference guards and actions by name as strings: guard: 'isValid'. The actual implementation lives in the second argument or in machine.provide({...}). If you never provided isValid, XState v5 throws a warning and treats the guard as truthy by default, so the transition fires when it shouldn’t. v4 silently skipped the guard. Either way, your transition appears to ignore the condition.
The second source is the v4-to-v5 API break. XState v5 is largely a rewrite. interpret became createActor. services became actors and require fromPromise/fromCallback/fromObservable wrappers. assign actions now receive a single object {context, event, ...} instead of (context, event). event.data from invoked services became event.output. schema was removed in favor of types. Code written against v4 docs throws cryptic errors when run on v5.
The third source is parallel and nested state matching. state.matches('loading') works for top-level states. For nested states under a parent, you need state.matches('parent.child') or state.matches({ parent: 'child' }). Parallel states require object form: state.matches({ form: 'editing', network: 'online' }). A matches() call with the wrong shape returns false silently and your conditional rendering breaks.
The fourth source is timing. actor.send() is synchronous in v5 — the snapshot updates before send returns. But actor.getSnapshot() after send returns the new snapshot, while actor.subscribe(callback) runs the callback synchronously after the send for already-subscribed listeners. In React, useMachine schedules a re-render but doesn’t re-render immediately, so logging state right after send inside the same handler shows the old value.
Diagnostic Timeline
Minute 0 — Machine transitions to the wrong state. Your first instinct is to fix the transition definition. Most of the time the definition is fine and the guard is misbehaving. Open the XState inspector (@statelyai/inspect) and replay the send — you’ll see whether the guard ran, whether it returned true, and which transition matched.
Minute 3 — Guard appears to always return true. Two causes. First, the guard isn’t provided in the machine implementation, so XState defaults to true. Confirm guards: { isValid: ... } exists in the second argument to createMachine or in machine.provide({...}). Second, the guard function returns a truthy value by accident — ({context}) => context.errors returns the array reference, which is truthy even when empty. Use context.errors.length === 0 instead.
Minute 8 — Parallel state matching returns false. If your machine has type: 'parallel' with regions, state.matches('regionA.stateB') does not match the parallel structure. Use the object form: state.matches({ regionA: 'stateB' }). Same for compound nested states inside a parallel parent.
Minute 15 — Component doesn’t re-render after send. Confirm you imported useMachine from @xstate/react, not from xstate. The plain xstate package has no React integration. If the import is correct, check that the component you’re updating actually consumes state from the same useMachine call — if you read from an actor ref via useSelector with a selector that returns the same reference, React won’t re-render.
Minute 22 — event.data is undefined inside onDone. This is the v4-to-v5 break. In v5, the invoked promise/observable result lands on event.output, not event.data. Change every ({event}) => event.data to ({event}) => event.output inside onDone actions.
Minute 30 — Child actor doesn’t receive events. sendTo('childId', ...) requires the child to have been spawned with that exact id. Spawn with spawn(childMachine, { id: 'childId' }) and confirm the parent still has the actor ref in context. If you stopped the child via stopChild('childId'), sending to it after is a no-op.
Minute 40 — Context isn’t what you expect after send. actor.send is synchronous, but React closures around state are stale until re-render. Read actor.getSnapshot().context inside the handler, or move logging into a useEffect keyed on state.context.
Minute 50 — Machine never reaches a final state in tests. waitFor rejects after timeout if the predicate never matches. The usual cause is a hanging invoked actor — fromPromise won’t resolve if your mock doesn’t return a settled promise.
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.
Guard returns truthy by accident — JavaScript’s coercion rules bite here. ({context}) => context.items returns the array reference, which is truthy regardless of length. ({context}) => context.user is truthy when user = {}. Always return an explicit boolean: ({context}) => context.items.length > 0. The XState typecheck plugin flags non-boolean returns if you enable strict guard typing.
Self-transition reruns entry actions unexpectedly — on: { EVENT: '.same_state' } is a self-transition that exits and re-enters the state, triggering exit and entry actions. If you only want to run an action without changing state, omit the target and use actions only: on: { EVENT: { actions: 'doThing' } }. The internal transition keeps the state intact.
fromCallback actor never stops emitting — actors created with fromCallback need to return a cleanup function. If your callback does setInterval but doesn’t return () => clearInterval(id), the interval keeps running after the parent stops the actor. This leaks until page reload. Always return cleanup.
TypeScript can’t infer event types in actions — XState v5’s type inference relies on the types field in the machine config. If you defined events as type Events = { type: 'FOO', value: string } | { type: 'BAR' } and your action destructures event.value, TypeScript only narrows the type if you discriminate first: if (event.type === 'FOO') { event.value }. Without the discriminant check, the property is string | undefined.
For related state management and reactivity issues, see Fix: Zustand Not Working, Fix: React useState Not Updating, Fix: Redux State Not Updating, and Fix: Jotai Not Working.
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.