| name | xstate-common-patterns |
| description | Ready-to-use XState v5 patterns for common scenarios. Use when implementing data fetching with loading/error/retry, form validation, authentication flows, debounce/throttle, retry with backoff, persistence, or CRUD with dynamic actors. Each pattern is a complete machine. |
XState v5 Common Patterns
Data Fetching
The most common pattern: idle → loading → success/failure with retry.
import { setup, assign, fromPromise } from 'xstate';
const fetchMachine = setup({
types: {
context: {} as {
data: unknown | null;
error: unknown | null;
},
events: {} as
| { type: 'FETCH'; url: string }
| { type: 'RETRY' }
| { type: 'RESET' },
},
actors: {
fetchData: fromPromise(async ({ input }: { input: { url: string } }) => {
const res = await fetch(input.url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}),
},
}).createMachine({
id: 'fetch',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: {
on: { FETCH: 'loading' },
},
loading: {
invoke: {
src: 'fetchData',
input: ({ event }) => ({ url: event.url }),
onDone: {
target: 'success',
actions: assign({ data: ({ event }) => event.output, error: null }),
},
onError: {
target: 'failure',
actions: assign({ error: ({ event }) => event.error }),
},
},
},
success: {
on: { FETCH: 'loading', RESET: 'idle' },
},
failure: {
on: {
RETRY: 'loading',
RESET: { target: 'idle', actions: assign({ error: null }) },
},
},
},
});
Retry with Exponential Backoff
const retryMachine = setup({
types: {
context: {} as {
data: unknown | null;
error: unknown | null;
attempts: number;
maxAttempts: number;
},
},
actors: {
fetchData: fromPromise(async ({ input }: { input: { url: string } }) => {
const res = await fetch(input.url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}),
},
guards: {
canRetry: ({ context }) => context.attempts < context.maxAttempts,
maxRetriesReached: ({ context }) => context.attempts >= context.maxAttempts,
},
delays: {
retryDelay: ({ context }) => Math.min(1000 * .(, context.), ),
},
}).({
: ,
: ,
: { : , : , : , : },
: {
: {
: {
: ,
: { : },
: {
: ,
: ({ : event. }),
},
: {
: ,
: ({
: event.,
: context. + ,
}),
},
},
},
: {
: [
{ : , : },
{ : , : },
],
},
: {
: {
: { : },
},
},
: { : },
: { : },
},
});
Form Validation + Submission
const formMachine = setup({
types: {
context: {} as {
fields: Record<string, string>;
errors: Record<string, string>;
submitError: string | null;
},
events: {} as
| { type: 'field.change'; field: string; value: string }
| { type: 'SUBMIT' }
| { type: 'RESET' },
},
actors: {
submitForm: fromPromise(async ({ input }: { input: { fields: Record<string, string> } }) => {
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(input.fields),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw ();
res.();
}),
},
: {
: .(context.). === ,
},
: {
: ( {
: <, > = {};
(!context..) errors. = ;
(!context..?.()) errors. = ;
{ errors };
}),
},
}).({
: ,
: ,
: { : {}, : {}, : },
: {
: {
: {
: {
: [
({
: ({
...context.,
[event.]: event.,
}),
}),
{ : },
],
},
: ,
},
},
: {
: { : },
: [
{ : , : },
{ : },
],
},
: {
: {
: ,
: ({ : context. }),
: { : },
: {
: ,
: ({ : event.?. ?? }),
},
},
},
: {
: { : { : , : ({ : {}, : {}, : }) } },
},
},
});
Authentication Flow
const authMachine = setup({
types: {
context: {} as {
token: string | null;
user: { id: string; name: string } | null;
error: string | null;
},
events: {} as
| { type: 'LOGIN'; email: string; password: string }
| { type: 'LOGOUT' }
| { type: 'TOKEN_EXPIRED' },
},
actors: {
authenticate: fromPromise(async ({ input }: { input: { email: string; password: string } }) => {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(input),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw ();
res.() <{ : ; : { : ; : } }>;
}),
: ( ({ input }: { : { : } }) => {
res = (, {
: ,
: { : },
});
(!res.) ();
res.() <{ : }>;
}),
},
}).({
: ,
: ,
: { : , : , : },
: {
: {
: ({ : , : }),
: { : },
},
: {
: {
: ,
: ({ : event., : event. }),
: {
: ,
: ({
: event..,
: event..,
: ,
}),
},
: {
: ,
: ({
: event.?. ?? ,
}),
},
},
},
: {
: {
: ,
: ,
},
},
: {
: {
: ,
: ({ : context.! }),
: {
: ,
: ({ : event.. }),
},
: ,
},
},
},
});
Debounce
Self-transition resets the delay timer:
const debounceMachine = setup({
types: {
context: {} as { query: string },
events: {} as { type: 'INPUT'; value: string },
},
delays: {
debounceDelay: 300,
},
actors: {
search: fromPromise(async ({ input }: { input: { query: string } }) => {
return fetch(`/api/search?q=${input.query}`).then(r => r.json());
}),
},
}).createMachine({
id: 'debounce',
initial: 'idle',
context: { query: '' },
states: {
idle: {
on: {
INPUT: {
target: 'debouncing',
actions: assign({ query: ({ event }) => event.value }),
},
},
},
debouncing: {
on: {
: {
: ,
: ,
: ({ : event. }),
},
},
: {
: ,
},
},
: {
: {
: ,
: ({ : context. }),
: { : },
: { : },
},
: {
: {
: ,
: ({ : event. }),
},
},
},
},
});
CRUD with Dynamic Actors
Use spawned actors for each entity:
import { setup, assign, spawnChild, stopChild, sendTo, fromPromise } from 'xstate';
const todoMachine = setup({
types: {
context: {} as { id: string; text: string; completed: boolean },
input: {} as { id: string; text: string },
events: {} as { type: 'TOGGLE' } | { type: 'UPDATE'; text: string },
},
}).createMachine({
context: ({ input }) => ({ id: input.id, text: input.text, completed: false }),
on: {
TOGGLE: { actions: assign({ completed: ({ context }) => !context.completed }) },
UPDATE: { actions: assign({ text: ({ event }) => event.text }) },
},
});
const todoListMachine = ({
: {
: {}
| { : ; : ; : }
| { : ; : }
| { : ; : },
},
: { : todoMachine },
}).({
: {
: {
: (, {
: ,
: ({ : event., : event. }),
}),
},
: {
: ( ),
},
: {
: (
,
{ : },
),
},
},
});
Persistence
Save and restore machine state across sessions:
import { createActor } from 'xstate';
const actor = createActor(myMachine).start();
actor.subscribe(() => {
const persistedState = actor.getPersistedSnapshot();
localStorage.setItem('app-state', JSON.stringify(persistedState));
});
const savedState = JSON.parse(localStorage.getItem('app-state') ?? 'null');
const restoredActor = createActor(myMachine, {
snapshot: savedState ?? undefined,
}).start();
Restore from State Value Only
const savedValue = localStorage.getItem('state-value');
const resolvedState = myMachine.resolveState({
value: savedValue,
});
const actor = createActor(myMachine, {
snapshot: resolvedState,
}).start();
React Integration
useActor / useMachine
import { useMachine } from '@xstate/react';
function FetchComponent() {
const [snapshot, send] = useMachine(fetchMachine);
if (snapshot.matches('idle')) {
return <button onClick={() => send({ type: 'FETCH', url: '/api/data' })}>Fetch</button>;
}
if (snapshot.matches('loading')) {
return <div>Loading...</div>;
}
if (snapshot.matches('success')) {
return <div>{JSON.stringify(snapshot.context.data)}</div>;
}
if (snapshot.matches('failure')) {
return (
<div>
<p>Error: {String(snapshot.context.error)}</p>
<button onClick={() => send({ type: 'RETRY' })}>Retry</button>
</div>
);
}
;
}
useSelector (optimized re-renders)
import { useActorRef, useSelector } from '@xstate/react';
const selectCount = (snapshot: SnapshotFrom<typeof counterMachine>) => snapshot.context.count;
function Counter() {
const actorRef = useActorRef(counterMachine);
const count = useSelector(actorRef, selectCount);
return <button onClick={() => actorRef.send({ type: 'increment' })}>Count: {count}</button>;
}
createActorContext (global state)
import { createActorContext } from '@xstate/react';
const AppContext = createActorContext(appMachine);
function App() {
return (
<AppContext.Provider>
<Child />
</AppContext.Provider>
);
}
function Child() {
const user = AppContext.useSelector((s) => s.context.user);
const actorRef = AppContext.useActorRef();
return <button onClick={() => actorRef.send({ type: 'LOGOUT' })}>{user?.name}</button>;
}