Skip to content

Fix: XState Not Working — Machine Not Transitioning, Guards Not Running, or Actor Not Sending Events

FixDevs ·

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 unconditionally

Or 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 update

Or 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 (via createActor or useMachine), XState either throws or silently skips the guard.
  • v5 broke most of the v4 API — XState v5 renamed services to actors, removed schema, changed assign usage, merged interpret into createActor, and changed how guards/actions are typed and provided.
  • useMachine requires the @xstate/react package — XState core and the React integration are separate packages. Using useMachine from xstate (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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles