Summary
- Model behavior with finite states and events.
- Keep logic outside React components.
- Add guards, actions, and services to reflect the real flow.
- Test transitions as pure functions.
Core ideas
- States: mutually exclusive (“idle”, “loading”, “success”, “failure”).
- Events: what happens (“SUBMIT”, “RESOLVE”, “REJECT”, “RETRY”).
- Guards: boolean conditions that gate transitions.
- Actions: synchronous effects (assign, log).
- Services: async work (fetch, timers).
- Determinism: given
(state, event)the next state is predictable.
Quickstart (TypeScript)
import { createMachine, assign } from 'xstate';
type Ctx = { query: string; data?: unknown; error?: string };
type Ev =
| { type: 'SUBMIT'; query: string }
| { type: 'RESOLVE'; data: unknown }
| { type: 'REJECT'; error: string }
| { type: 'RETRY' };
export const searchMachine = createMachine<Ctx, Ev>({
id: 'search',
initial: 'idle',
context: { query: '' },
states: {
idle: {
on: {
SUBMIT: {
target: 'loading',
cond: 'hasQuery',
actions: 'setQuery',
},
},
},
loading: {
invoke: { src: 'runSearch' },
on: {
RESOLVE: { target: 'success', actions: 'setData' },
REJECT: { target: 'failure', actions: 'setError' },
},
},
success: { on: { RETRY: 'idle' } },
failure: { on: { RETRY: 'idle' } },
},
},
{
guards: {
hasQuery: (_ctx, ev) => ev.type === 'SUBMIT' && ev.query.trim().length > 0,
},
actions: {
setQuery: assign((ctx, ev) =>
ev.type === 'SUBMIT' ? { ...ctx, query: ev.query } : ctx
),
setData: assign((ctx, ev) =>
ev.type === 'RESOLVE' ? { ...ctx, data: ev.data, error: undefined } : ctx
),
setError: assign((ctx, ev) =>
ev.type === 'REJECT' ? { ...ctx, error: ev.error, data: undefined } : ctx
),
},
});
React hook
import { useMachine } from '@xstate/react';
import { searchMachine } from './machine';
export function SearchBox() {
const [state, send] = useMachine(searchMachine, {
services: {
runSearch: async (ctx) => {
const r = await fetch(`/api/search?q=${encodeURIComponent(ctx.query)}`);
if (!r.ok) throw new Error('network');
return r.json();
},
},
});
return (
<div>
{/* UI derived from state */}
{state.matches('idle') && (
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const q = new FormData(form).get('q') as string;
send({ type: 'SUBMIT', query: q });
}}
>
<input name="q" placeholder="Search..." />
<button type="submit">Go</button>
</form>
)}
{state.matches('loading') && <p>Loading…</p>}
{state.matches('success') && <pre>{JSON.stringify(state.context.data, null, 2)}</pre>}
{state.matches('failure') && (
<div>
<p role="alert">Error: {state.context.error}</p>
<button onClick={() => send({ type: 'RETRY' })}>Try again</button>
</div>
)}
</div>
);
}
Unit tests without React
import { searchMachine } from './machine';
import { createActor } from 'xstate';
test('happy path', async () => {
const actor = createActor(searchMachine.provide({
guards: searchMachine.options!.guards!,
actions: searchMachine.options!.actions!,
services: {
runSearch: async () => ({ ok: true }),
},
})).start();
actor.send({ type: 'SUBMIT', query: 'xstate' });
expect(actor.getSnapshot().value).toBe('loading');
actor.send({ type: 'RESOLVE', data: { ok: true } });
expect(actor.getSnapshot().value).toBe('success');
});
When to use
-
Flows with 3+ states or tricky branches.
-
You need predictability, clear guards, and cheap tests.
-
You want a single source of truth for UI + business transitions.
Template
createMachine<Ctx, Ev>({
id: '<name>',
initial: '<state>',
context: {/* … */},
states: {
/* stateA: { on: { EVENT: { target: 'stateB', cond, actions } } } */
},
}, { guards: {/* … */}, actions: {/* … */}, services: {/* … */} });
References
-
Course: State Modeling in React with XState — David Khourshid.
-
Library: XState docs and @xstate/react examples.