Files
2025-11-29 18:01:45 +08:00

12 KiB

XState v5 Actors Reference

Actor Model Fundamentals

Actors are independent entities that:

  • Maintain private, encapsulated state
  • Communicate via asynchronous message passing (events)
  • Process messages sequentially from an internal mailbox
  • Can create new child actors
  • Can only expose internal state through events or snapshots

Actor Types Overview

Actor Type Receives Events Sends Events Spawns Actors Has Output Use Case
State Machine Yes Yes Yes Yes Complex state logic
Promise No Yes No Yes Async operations
Transition Yes Yes No No Pure reducers
Callback Yes Yes No No Imperative logic
Observable No Yes No No Streams

Creating and Managing Actors

Basic Actor Creation

import { createActor } from 'xstate';

const actor = createActor(someLogic, {
  id: 'myActor',
  input: { initialData: 'value' },
  snapshot: previousSnapshot, // For restoration
  systemId: 'mySystem',
  logger: console.log,
});

// Lifecycle
actor.start(); // Must call to begin processing
actor.stop(); // Stops actor and all children

Actor Subscription

// Simple observer function
const subscription = actor.subscribe((snapshot) => {
  console.log('State:', snapshot.value);
  console.log('Context:', snapshot.context);
});

// Full observer object
actor.subscribe({
  next(snapshot) {
    console.log('New snapshot:', snapshot);
  },
  error(err) {
    console.error('Actor error:', err);
  },
  complete() {
    console.log('Actor completed');
  },
});

// Cleanup
subscription.unsubscribe();

Snapshot Access

// Synchronous snapshot access
const currentSnapshot = actor.getSnapshot();

// Persistable snapshot (serializable)
const persistedSnapshot = actor.getPersistedSnapshot();
localStorage.setItem('actor-state', JSON.stringify(persistedSnapshot));

State Machine Actors

The most powerful actor type, implementing full statechart semantics.

import { createMachine, createActor } from 'xstate';

const toggleMachine = createMachine({
  id: 'toggle',
  initial: 'inactive',
  context: { count: 0 },
  states: {
    inactive: {
      on: {
        TOGGLE: {
          target: 'active',
          actions: assign({ count: ({ context }) => context.count + 1 }),
        },
      },
    },
    active: {
      on: { TOGGLE: 'inactive' },
    },
  },
});

const actor = createActor(toggleMachine);
actor.start();
actor.send({ type: 'TOGGLE' });

Promise Actors

Handle single async operations that resolve or reject.

import { fromPromise, createActor } from 'xstate';

// Basic promise actor
const fetchLogic = fromPromise(async () => {
  const response = await fetch('/api/data');
  return response.json();
});

// With input parameter
const fetchWithInput = fromPromise(async ({ input }) => {
  const response = await fetch(`/api/data/${input.id}`);
  return response.json();
});

// With abort signal
const cancellableFetch = fromPromise(async ({ input, signal }) => {
  const response = await fetch('/api/data', { signal });
  return response.json();
});

// Usage
const actor = createActor(fetchWithInput, {
  input: { id: '123' },
});

actor.subscribe({
  next: (snapshot) => {
    if (snapshot.status === 'done') {
      console.log('Result:', snapshot.output);
    }
  },
  error: (err) => console.error('Failed:', err),
});

actor.start();

Transition Actors

Pure reducers that transform state based on events.

import { fromTransition, createActor } from 'xstate';

const counterLogic = fromTransition(
  (state, event) => {
    switch (event.type) {
      case 'INC':
        return { ...state, count: state.count + 1 };
      case 'DEC':
        return { ...state, count: state.count - 1 };
      case 'RESET':
        return { count: 0 };
      default:
        return state;
    }
  },
  { count: 0 }, // Initial state
);

const actor = createActor(counterLogic);
actor.start();
actor.send({ type: 'INC' });
console.log(actor.getSnapshot().context.count); // 1

Callback Actors

Imperative actors for complex side effects and cleanup.

import { fromCallback, createActor } from 'xstate';

const intervalLogic = fromCallback(({ sendBack, receive, input }) => {
  // Setup
  console.log('Starting with input:', input);

  const interval = setInterval(() => {
    sendBack({ type: 'TICK', timestamp: Date.now() });
  }, 1000);

  // Event handler
  receive((event) => {
    if (event.type === 'STOP_TICKING') {
      clearInterval(interval);
    }
  });

  // Cleanup function (called on stop)
  return () => {
    clearInterval(interval);
    console.log('Cleaned up');
  };
});

const actor = createActor(intervalLogic, {
  input: { message: 'Timer started' },
});

actor.subscribe((snapshot) => {
  console.log('Snapshot:', snapshot);
});

actor.start();
// Later: actor.send({ type: 'STOP_TICKING' });
// Or: actor.stop();

Observable Actors

Integration with reactive streams (RxJS, etc.).

import { fromObservable, createActor } from 'xstate';
import { interval } from 'rxjs';
import { map } from 'rxjs/operators';

const timerLogic = fromObservable(() =>
  interval(1000).pipe(map((i) => ({ type: 'TICK', count: i }))),
);

const actor = createActor(timerLogic);
actor.subscribe((snapshot) => {
  console.log('Event from observable:', snapshot.context);
});
actor.start();

Invoking vs Spawning Actors

Invoked Actors (State-based)

Tied to state lifecycle - start on entry, stop on exit.

const machine = createMachine({
  states: {
    loading: {
      invoke: {
        id: 'fetcher',
        src: fromPromise(async () => {
          const res = await fetch('/api');
          return res.json();
        }),
        input: ({ context }) => ({ url: context.apiUrl }),
        onDone: {
          target: 'success',
          actions: assign({
            data: ({ event }) => event.output,
          }),
        },
        onError: {
          target: 'failure',
          actions: assign({
            error: ({ event }) => event.error,
          }),
        },
        onSnapshot: {
          actions: ({ event }) => {
            console.log('Invoked actor snapshot:', event.snapshot);
          },
        },
      },
    },
    success: {},
    failure: {},
  },
});

Spawned Actors (Action-based)

Created dynamically, managed manually.

import { spawn, stopChild } from 'xstate';

const parentMachine = createMachine({
  context: {
    todos: [],
    todoActors: {},
  },
  on: {
    ADD_TODO: {
      actions: assign({
        todoActors: ({ context, spawn }) => {
          const id = Math.random().toString();
          const todoActor = spawn(todoMachine, {
            id: `todo-${id}`,
            input: { text: event.text },
            syncSnapshot: true, // Sync snapshots with parent
          });

          return {
            ...context.todoActors,
            [id]: todoActor,
          };
        },
      }),
    },
    REMOVE_TODO: {
      actions: [
        stopChild(({ context, event }) => `todo-${event.id}`),
        assign({
          todoActors: ({ context, event }) => {
            const { [event.id]: removed, ...rest } = context.todoActors;
            return rest;
          },
        }),
      ],
    },
  },
});

Actor Communication

Sending Between Actors

import { sendTo, sendParent } from 'xstate';

const childMachine = createMachine({
  entry: [
    // Send to parent
    sendParent({ type: 'CHILD_READY' }),
    // Or use emit (preferred in v5)
    emit({ type: 'CHILD_READY' }),
  ],
  on: {
    PING: {
      actions: emit({ type: 'PONG' }),
    },
  },
});

const parentMachine = createMachine({
  invoke: {
    id: 'child',
    src: childMachine,
  },
  on: {
    SEND_TO_CHILD: {
      actions: sendTo('child', { type: 'PING' }),
    },
    PONG: {
      actions: log('Received pong from child'),
    },
  },
});

Actor System

// Access system from any actor
const actor = createActor(machine);
actor.start();

// Get actor from system
const childActor = actor.system.get('childId');

// Inspect all actors in system
actor.system._set.forEach((actor, id) => {
  console.log(`Actor ${id}:`, actor.getSnapshot());
});

Waiting for Actors

import { waitFor } from 'xstate';

// Wait for condition
const doneSnapshot = await waitFor(
  actor,
  (snapshot) => snapshot.status === 'done',
  { timeout: 5000 }, // Optional timeout
);

// Convert to promise
import { toPromise } from 'xstate';

const result = await toPromise(actor);
// Resolves with output when done
// Rejects with error when failed

Error Handling

Promise Actor Errors

const errorProneLogic = fromPromise(async () => {
  throw new Error('Something went wrong');
});

const machine = createMachine({
  invoke: {
    src: errorProneLogic,
    onError: {
      target: 'errorState',
      actions: ({ event }) => {
        console.error('Error:', event.error);
      },
    },
  },
});

Callback Actor Error Reporting

const callbackWithErrors = fromCallback(({ sendBack }) => {
  try {
    // risky operation
  } catch (error) {
    sendBack({ type: 'error.platform', error });
  }
});

Actor Persistence

// Save actor state
const actor = createActor(machine);
actor.subscribe(() => {
  const snapshot = actor.getPersistedSnapshot();
  localStorage.setItem('actor-state', JSON.stringify(snapshot));
});
actor.start();

// Restore actor state
const savedState = localStorage.getItem('actor-state');
if (savedState) {
  const restoredActor = createActor(machine, {
    snapshot: JSON.parse(savedState),
  });
  restoredActor.start();
}

Best Practices

1. Choose the Right Actor Type

  • State machines: Complex logic with multiple states
  • Promises: Single async operations
  • Transitions: Pure state reducers
  • Callbacks: Imperative code with cleanup
  • Observables: Stream integrations

2. Actor Lifecycle Management

// Always subscribe before starting
const subscription = actor.subscribe(observer);
actor.start();

// Clean up properly
subscription.unsubscribe();
actor.stop();

3. Error Boundaries

// Always handle errors in parent
const parentMachine = createMachine({
  invoke: {
    src: childLogic,
    onError: {
      actions: [
        log('Child failed'),
        assign({ error: ({ event }) => event.error }),
      ],
    },
  },
});

4. Input Validation

const actorWithInput = fromPromise(async ({ input }) => {
  // Validate input
  if (!input?.id) {
    throw new Error('ID required');
  }
  return fetchData(input.id);
});

5. Sync vs Async Snapshots

// Spawned actors can sync snapshots with parent
spawn(childMachine, {
  id: 'child',
  syncSnapshot: true, // Parent re-renders on child changes
});

Testing Actors

import { createActor } from 'xstate';
import { expect, test } from 'vitest';

test('actor processes events correctly', async () => {
  const actor = createActor(machine);

  const states = [];
  actor.subscribe((snapshot) => {
    states.push(snapshot.value);
  });

  actor.start();
  actor.send({ type: 'EVENT1' });
  actor.send({ type: 'EVENT2' });

  expect(states).toEqual(['initial', 'state1', 'state2']);
});

test('promise actor resolves', async () => {
  const actor = createActor(promiseLogic);
  actor.start();

  const result = await toPromise(actor);
  expect(result).toEqual({ data: 'expected' });
});