Handle async requests
Build a search feature with debounce, cancellation, errors and retry.
XState v6 is in alpha
This guide builds a complete search box. It debounces typing, cancels a request when the query changes, shows errors, retries on demand, and renders from the actor.
1. Write the async logic
createAsyncLogic(...) describes one request. Its input schema types and validates what the request needs. Pass the signal to fetch so XState can abort the request when its state is exited.
import { createActor, createAsyncLogic, setup } from 'xstate';
import { z } from 'zod';
const searchLogic = createAsyncLogic({
schemas: { input: z.object({ query: z.string() }) },
run: async ({ input, signal }) => {
const response = await fetch(
`/api/search?q=${encodeURIComponent(input.query)}`,
{ signal }
);
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}`);
}
return response.json() as Promise<string[]>;
}
});2. Define the schemas
Schemas give the machine its context and event types, and validate them at runtime.
const searchSetup = setup({
schemas: {
context: z.object({
query: z.string(),
results: z.array(z.string()),
errorMessage: z.string().nullable()
}),
events: {
'query.change': z.object({ query: z.string() }),
search: z.object({}),
retry: z.object({})
}
},
actors: { searchLogic }
});3. Model the states
Typing is handled at the root, so a new query is accepted in every state. The transition cancels the pending delayed search event and raises a new one, which is the debounce.
const searchMachine = searchSetup.createMachine({
context: { query: '', results: [], errorMessage: null },
initial: 'idle',
on: {
'query.change': ({ context, event }, enq) => {
enq.cancel('debounce');
enq.raise({ type: 'search' }, { delay: 300, id: 'debounce' });
return {
target: '.debouncing',
context: { ...context, query: event.query }
};
},
search: { target: '.searching' }
},
states: {
idle: {},
debouncing: {},
searching: {
invoke: {
src: 'searchLogic',
input: ({ context }) => ({ query: context.query }),
timeout: 10_000,
onDone: ({ context, event }) => ({
target: 'success',
context: { ...context, results: event.output, errorMessage: null }
}),
onError: ({ context, event }) => ({
target: 'failure',
context: { ...context, errorMessage: String(event.error) }
}),
onTimeout: ({ context }) => ({
target: 'failure',
context: { ...context, errorMessage: 'Search timed out' }
})
}
},
success: {},
failure: { on: { retry: { target: 'searching' } } }
}
});Three behaviors come out of this shape:
- Debounce. Each keystroke cancels the delayed
searchevent and schedules another one 300ms later. The event is only delivered after a 300ms pause in typing. - Stale request cancellation. A keystroke during
searchingleaves that state, which stops the invoked actor and aborts itsfetch. A response that arrives after that is ignored. - Retry.
failurekeeps the query in context, soretryre-enterssearchingwith the same input.
4. Wire it to the UI
actor.select(...) returns a readable for one derived value. It notifies only when that value changes, so each part of the UI updates independently.
const actor = createActor(searchMachine).start();
const input = document.querySelector('input')!;
const list = document.querySelector('ul')!;
const status = document.querySelector('.status')!;
const retryButton = document.querySelector('button')!;
input.addEventListener('input', () => {
actor.send({ type: 'query.change', query: input.value });
});
retryButton.addEventListener('click', () => {
actor.send({ type: 'retry' });
});
const results = actor.select((snapshot) => snapshot.context.results);
const state = actor.select((snapshot) => snapshot.value);
results.subscribe((values) => {
list.replaceChildren(
...values.map((value) => {
const item = document.createElement('li');
item.textContent = value;
return item;
})
);
});
state.subscribe((value) => {
status.textContent = value === 'searching' ? 'Searching…' : '';
retryButton.hidden = value !== 'failure';
});Warning:
subscribe(...)does not replay the current value. Callresults.get()andstate.get()once for the first render.
In a component, use your framework's binding instead of subscribing by hand. See use with your framework for React, Vue, Svelte and Solid.
What next?
- Async logic for why requests are modeled as states.
- Invoke actors for the full
invokeconfiguration, includingonSnapshot. - Add retries and timeouts to limit attempts and back off between them.
- Selectors for derived reads and custom equality.