Managing complex asynchronous application workflows using scattered boolean flags (isLoading, isSubmitting, hasError, canRetry) inevitably leads to a combinatorial explosion of invalid states. In traditional UI applications, four independent boolean flags yield 16 possible state permutations, most of which are logically impossible or broken (such as isLoading === true and isSuccess === true being simultaneously active).
The formal mathematical remedy is Finite State Machines (FSM) and Harel Statecharts. XState v5 introduces a complete architectural reimagining of the library centered on the Actor Model.
In XState v4, static typing required an external CLI code generator (@xstate/cli) producing auxiliary *.typegen.d.ts files. In XState v5, this external infrastructure was eliminated in favor of compiler-native type inference enabled by the setup() function. Context, events, actors, guards, and actions are now inferred synchronously by the TypeScript compiler with zero external code generation tooling.
+-------------------------------------------------------------------------+
| TYPE INFERENCE ARCHITECTURE IN XSTATE V5 |
+-------------------------------------------------------------------------+
| [setup({ types: {} as { context, events, input }, actors, actions })] |
| |--> Single source of truth for state machine contracts |
| |--> Seamless TypeScript inference without CLI typegen artifacts |
+-------------------------------------------------------------------------+
|
v
| [machine = setup(...).createMachine({ ... })] |
| |--> Strict state node validation (states: { idle, loading }) |
| |--> Typed guard functions: ({ context, event }) => boolean |
| `-- Typed assign reducers: ({ context, event }) => nextContext |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [actor = createActor(machine, { input: { initialBudget: 100 } })] |
| |-- actor.start() |
| |-- actor.send({ type: 'SUBMIT', payload: 'data' }) |
| `-- actor.getSnapshot().matches({ active: 'processing' }) |
+-------------------------------------------------------------------------+In XState v5, systems are modeled as concurrent, message-passing actors:
fromPromise, fromObservable, fromCallback, fromTransition).Actor Interaction Topology in XState v5:
+-------------------------------------------------------------+
| Root Actor: CheckoutMachine |
| |
| [State: validating] |
| | |
| | invoke: fromPromise(verifyCardApi) |
| v |
| +---------------------------------------------------------+ |
| | Child Actor: CardVerifier (Promise) | |
| | Executes network verification against payment gateway | |
| +---------------------------------------------------------+ |
| | |
| |-- onDone: assign(cardData) -> transition to [ready]|
| `-- onError: assign(error) -> transition to [error]|
+-------------------------------------------------------------+Disconnected -> Connecting -> Authenticating -> Connected -> Reconnecting cycles with exponential backoff.setup()#Below is an authentication state machine featuring a session timer, credential verification, and lockouts:
import { setup, assign, fromPromise } from 'xstate';
// 1. Data contracts for context, events, and inputs
export interface UserSession {
readonly userId: string;
readonly email: string;
readonly role: 'admin' | 'user';
}
export interface AuthContext {
readonly session: UserSession | null;
readonly errorMessage: string | null;
readonly retryCount: number;
}
export type AuthEvent =
| { type: 'LOGIN'; readonly email: string; readonly pass: string }
| { type: 'LOGOUT' }
| { type: 'RETRY' };
export interface AuthInput {
readonly defaultRetryLimit: number;
}
// 2. Asynchronous authentication actor
const loginService = fromPromise<
UserSession,
{ readonly email: string; readonly pass: string }
>(async ({ input }) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error(`[AuthError] Invalid credentials (${response.status})`);
}
return (await response.json()) as UserSession;
});
// 3. Strongly typed machine configuration via setup()
export const authMachine = setup({
types: {
context: {} as AuthContext,
events: {} as AuthEvent,
input: {} as AuthInput,
},
actors: {
loginService,
},
guards: {
canRetry: ({ context }, params: { limit: number }) =>
context.retryCount < params.limit,
},
actions: {
notifyAdmin: ({ context }) => {
console.warn(`[Security Alert] Max login attempts exhausted: ${context.errorMessage}`);
},
},
}).createMachine({
id: 'authentication',
initial: 'idle',
context: ({ input }) => ({
session: null,
errorMessage: null,
retryCount: 0,
}),
states: {
idle: {
on: {
LOGIN: {
target: 'authenticating',
actions: assign({
errorMessage: () => null,
}),
},
},
},
authenticating: {
invoke: {
id: 'loginActor',
src: 'loginService',
input: ({ event }) => {
// Discriminated event narrowed strictly to LOGIN
if (event.type !== 'LOGIN') {
throw new Error('Expected LOGIN event');
}
return { email: event.email, pass: event.pass };
},
onDone: {
target: 'authenticated',
actions: assign({
session: ({ event }) => event.output,
retryCount: () => 0,
}),
},
onError: {
target: 'failure',
actions: assign({
errorMessage: ({ event }) =>
event.error instanceof Error ? event.error.message : 'Unknown error',
retryCount: ({ context }) => context.retryCount + 1,
}),
},
},
},
authenticated: {
on: {
LOGOUT: {
target: 'idle',
actions: assign({
session: () => null,
}),
},
},
},
failure: {
on: {
RETRY: [
{
guard: { type: 'canRetry', params: { limit: 3 } },
target: 'idle',
},
{
target: 'locked',
actions: 'notifyAdmin',
},
],
},
},
locked: {
type: 'final',
},
},
});In XState v5, actors are instantiated using createActor(), passing the machine and initialization input:
import { createActor } from 'xstate';
import { authMachine } from '../machines/authMachine';
export function initializeAuthSystem() {
// Actor instantiation with required input parameter
const actor = createActor(authMachine, {
input: { defaultRetryLimit: 3 },
});
// Subscribe to state snapshot transitions
actor.subscribe((snapshot) => {
console.log(`Current state: ${String(snapshot.value)}`);
// snapshot.matches() is strictly autocompleted across declared state nodes!
if (snapshot.matches('authenticated')) {
console.log('User authenticated:', snapshot.context.session?.email);
} else if (snapshot.matches('locked')) {
console.error('Account locked due to excessive failed attempts');
}
});
actor.start();
// Sending typed event messages
actor.send({
type: 'LOGIN',
email: '[email protected]',
pass: 'Secr3t!P@ssw0rd',
});
return actor;
}The UI layer or WebSocket client deposits a strongly typed event payload into the actor's mailbox.
The actor executes pure predicate functions. If false, transitions are rejected or routed to fallback branches.
assign actions calculate the subsequent context object without mutating the existing memory structure.
The actor transitions to the target state, spins up invoked actors, and broadcasts a fresh immutable Snapshot.
import { setup, assign, fromCallback } from 'xstate';
export interface CountdownContext {
readonly remainingSeconds: number;
readonly initialSeconds: number;
}
export type CountdownEvent =
| { type: 'START' }
| { type: 'PAUSE' }
| { type: 'RESET' }
| { type: 'TICK' };
export interface CountdownInput {
readonly durationSeconds: number;
}
// Interval timer actor implemented via fromCallback
const timerService = fromCallback<CountdownEvent>(({ sendBack }) => {
const intervalId = setInterval(() => {
sendBack({ type: 'TICK' });
}, 1000);
return () => {
clearInterval(intervalId);
};
});
export const countdownMachine = setup({
types: {
context: {} as CountdownContext,
events: {} as CountdownEvent,
input: {} as CountdownInput,
},
actors: {
timerService,
},
}).createMachine({
id: 'countdown',
initial: 'idle',
context: ({ input }) => ({
remainingSeconds: input.durationSeconds,
initialSeconds: input.durationSeconds,
}),
states: {
idle: {
on: {
START: 'running',
RESET: {
actions: assign({
remainingSeconds: ({ context }) => context.initialSeconds,
}),
},
},
},
running: {
invoke: {
id: 'timerTicker',
src: 'timerService',
},
on: {
PAUSE: 'paused',
RESET: {
target: 'idle',
actions: assign({
remainingSeconds: ({ context }) => context.initialSeconds,
}),
},
TICK: [
{
guard: ({ context }) => context.remainingSeconds <= 1,
target: 'completed',
actions: assign({ remainingSeconds: () => 0 }),
},
{
actions: assign({
remainingSeconds: ({ context }) => context.remainingSeconds - 1,
}),
},
],
},
},
paused: {
on: {
START: 'running',
RESET: {
target: 'idle',
actions: assign({
remainingSeconds: ({ context }) => context.initialSeconds,
}),
},
},
},
completed: {
type: 'final',
},
},
});In XState v5, using the legacy generic syntax createMachine<Context, Event> disables automated type inference across actions, actors, and inputs, causing parameters in action handlers to fall back to any.
// ❌ WRONG: Legacy v4 generic syntax
const badMachine = createMachine<Context, Event>({ ... });
// ✅ CORRECT: XState v5 standard via setup()
const goodMachine = setup({
types: {
context: {} as Context,
events: {} as Event,
},
}).createMachine({ ... });The context object is strictly frozen. Attempting in-place mutations like context.count++ throws runtime errors in strict mode or prevents actors from detecting state changes.
// ❌ WRONG: In-place context mutation
actions: ({ context }) => {
context.retryCount += 1; // FORBIDDEN: Mutates frozen context!
}
// ✅ CORRECT: Immutable derivation using assign
actions: assign({
retryCount: ({ context }) => context.retryCount + 1,
})In the Actor Model, an actor instance returned by createActor(machine) is initialized in an unstarted, dormant state. Events dispatched via actor.send() before calling actor.start() are dropped silently.
// ❌ WRONG: Dispatching to dormant actor
const actor = createActor(machine);
actor.send({ type: 'EVENT' }); // LOST!
// ✅ CORRECT: Starting actor prior to message dispatch
const actor = createActor(machine);
actor.start();
actor.send({ type: 'EVENT' });Inside invoke.input: ({ event }) => ..., the event object represents the union of all events capable of triggering the parent state node. If multiple events transition into the state, accessing event properties without a discriminator guard causes TypeScript error TS2339.
// ✅ CORRECT: Guarded event discrimination
input: ({ event }) => {
if (event.type === 'LOGIN') {
return { email: event.email };
}
return { email: '' };
}Define distinct lifecycle states (idle, authenticating, active, error) and the events triggering transitions.
Define context, events, and input schemas via phantom casts (types: {} as { ... }).
Implement asynchronous side-effects using fromPromise or fromCallback inside the actors block.
Structure state transitions, enforce business invariants using guards, and update data with assign.
Spawn the actor using createActor() and bind it to React components using @xstate/react hooks.