How do you handle asynchronous code execution and state updates in React?
Run async in useEffect with AbortController for cancellation, or use React Query/SWR for caching, retries, and dedup. Inside event handlers, await freely but guard against unmounts. For state updates from async, use functional updaters to avoid stale closures. React 18 batches setState across promises automatically. For race conditions (rapid inputs), cancel previous in-flight requests or track a request id and discard outdated responses.
Async work + React state has predictable failure modes. Here's the playbook.
In an event handler
async function onSave() {
setSaving(true);
try {
const result = await api.save(form);
setData(result);
} catch (err) {
setError(err as Error);
} finally {
setSaving(false);
}
}React 18 batches all three setState calls into one render — even though they're across awaits.
In useEffect
useEffect(() => {
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal })
.then(r => r.json())
.then(data => setData(data))
.catch(err => {
if (err.name === 'AbortError') return;
setError(err);
});
return () => ctrl.abort();
}, [url]);Critical: return a cleanup that aborts. Otherwise a stale fetch will resolve after unmount or after url changed, and setState fires on a no-longer-relevant component.
Better: React Query
const { data, isLoading, error } = useQuery({
queryKey: ['user', id],
queryFn: ({ signal }) => fetch(`/user/${id}`, { signal }).then(r => r.json()),
});React Query handles dedup, retry, abort on unmount, stale-while-revalidate, refetch on focus. Skip the hand-rolled useEffect for fetches.
Race conditions: rapid input
User types 'hello' fast. Each keystroke fires a fetch. Responses can arrive out of order.
Fix A: AbortController on every new request.
useEffect(() => {
const ctrl = new AbortController();
fetch(`/search?q=${q}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setResults)
.catch(err => err.name !== 'AbortError' && setError(err));
return () => ctrl.abort();
}, [q]);Fix B: request id.
const reqIdRef = useRef(0);
async function search(q: string) {
const id = ++reqIdRef.current;
const results = await api.search(q);
if (id !== reqIdRef.current) return; // outdated, ignore
setResults(results);
}Stale closures
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count captured as initial value
}, 1000);
return () => clearInterval(id);
}, []);Fix: functional updater.
setCount(c => c + 1);setState after unmount
Warning: Can't perform a React state update on an unmounted component.React 18 silenced this warning but the leak is real. AbortController solves it cleanly; alternatively:
useEffect(() => {
let alive = true;
api.load().then(d => { if (alive) setData(d); });
return () => { alive = false; };
}, []);Suspense + use() (React 19)
function Page({ promise }: { promise: Promise<Data> }) {
const data = use(promise); // Suspense triggers fallback while pending
return <View data={data} />;
}The promise must be created outside the component (otherwise it's recreated each render).
Server actions / mutations
const mutation = useMutation({
mutationFn: (item: Item) => api.create(item),
onSuccess: () => qc.invalidateQueries({ queryKey: ['items'] }),
});
mutation.mutate(newItem);Patterns to avoid
- fetch in render — fires every render, leaks aborts.
- await in render — components can't be async (except RSC).
- setState in render — infinite loop.
- await without try/catch — unhandled rejections.
Summary cheat sheet
| Situation | Tool |
|---|---|
| Click handler that calls API | async function, try/catch, setState |
| Fetch tied to props | useEffect + AbortController, or React Query |
| Many independent fetches | useQueries / Promise.all |
| Mutation with optimistic UI | useMutation + onMutate/onError/onSettled |
| Race condition on rapid input | AbortController or reqId |
| Async work across renders | Suspense + use() (React 19) |
| Stale closure inside interval | functional updater + empty deps |
Senior framing
Async in React isn't the hard part — staleness is. Closures over old state, abort on unmount, race ordering on rapid inputs. Pick a library (React Query) for fetches; reach for AbortController + functional updaters elsewhere.
Follow-up questions
- •When do you reach for AbortController vs a request-id check?
- •Why is React Query usually preferable to hand-rolled useEffect fetches?
- •How does React 18 batch setState across awaits?
Common mistakes
- •Reading captured state inside an interval — stale closure.
- •Forgetting to abort fetches in useEffect cleanup.
- •setState in render — infinite loop.
Performance considerations
- •Abort on unmount frees memory and prevents wasted work. Dedup via React Query when many components fetch the same data. Batch async setStates relying on React 18 automatic batching.
Edge cases
- •Promise.all rejects on first failure; allSettled is partial-success-friendly.
- •AbortController doesn't actually cancel the network request mid-flight on all platforms — it cancels the JS side.
- •Suspense + use() requires the promise to be cached/stable outside the render.
Real-world examples
- •Search-as-you-type, file uploads, payment flows, infinite scroll, optimistic carts. Each has a different async story but the pattern repeats: handle staleness, abort, batch.