Back to React
React
medium
mid

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.

7 min read·~5 min to think through

Async work + React state has predictable failure modes. Here's the playbook.

In an event handler

tsx
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

tsx
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

tsx
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.

tsx
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.

tsx
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

tsx
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // count captured as initial value
  }, 1000);
  return () => clearInterval(id);
}, []);

Fix: functional updater.

tsx
setCount(c => c + 1);

setState after unmount

ts
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:

tsx
useEffect(() => {
  let alive = true;
  api.load().then(d => { if (alive) setData(d); });
  return () => { alive = false; };
}, []);

Suspense + use() (React 19)

tsx
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

tsx
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

SituationTool
Click handler that calls APIasync function, try/catch, setState
Fetch tied to propsuseEffect + AbortController, or React Query
Many independent fetchesuseQueries / Promise.all
Mutation with optimistic UIuseMutation + onMutate/onError/onSettled
Race condition on rapid inputAbortController or reqId
Async work across rendersSuspense + use() (React 19)
Stale closure inside intervalfunctional 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.

Senior engineer discussion

Senior framing: most 'async bugs' in React are really 'I'm not handling staleness'. The closure model + state-after-unmount + race-on-rapid-input set is the entire problem. Get those three patterns right and React's async story is straightforward.

Related questions