Ch. 3 · React

React Fetch Race Conditions: Cleanup and AbortController

Fix out-of-order React fetch results with cleanup and AbortController. Includes loading states, error handling and a practical interview explanation.

~3 min readintermediate

A React request race happens when an older request finishes after a newer one and replaces the result the user actually asked for. Imagine selecting Ada, then Grace. If Grace’s response arrives first and Ada’s arrives later, blindly accepting both responses leaves Ada on a screen labeled Grace.

The fix is to associate updates with the active request. An Effect cleanup can mark its request as obsolete and abort the fetch. Those are related but different actions: the guard controls whether a result may update this view, while cancellation asks supported work to stop.

A request-scoped implementation

This example assumes /api/users/:id returns JSON containing an id and name. It shows the lifecycle pattern; validate external response shapes at a suitable data boundary in a production application.

import { useEffect, useState } from 'react';

export function UserDetails({ userId }) {
  const [result, setResult] = useState({
    id: null, status: 'idle', user: null, error: ''
  });

  useEffect(() => {
    if (!userId) return;
    const controller = new AbortController();
    let active = true;

    setResult({ id: userId, status: 'loading', user: null, error: '' });

    async function load() {
      try {
        const response = await fetch(
          `/api/users/${encodeURIComponent(userId)}`,
          { signal: controller.signal }
        );
        if (!response.ok) throw new Error(`Request failed (${response.status})`);
        const user = await response.json();
        if (active) {
          setResult({ id: userId, status: 'success', user, error: '' });
        }
      } catch (error) {
        if (!active) return;
        setResult({
          id: userId, status: 'error', user: null,
          error: error instanceof Error ? error.message : 'Request failed'
        });
      }
    }

    load();
    return () => {
      active = false;
      controller.abort();
    };
  }, [userId]);

  if (!userId) return <p>Select a user.</p>;
  // Do not show the previous user's result before the new Effect runs.
  if (result.id !== userId || result.status === 'loading') {
    return <p role="status">Loading user…</p>;
  }
  if (result.status === 'error') return <p role="alert">{result.error}</p>;
  return <h2>{result.user?.name}</h2>;
}
JSX

The cleanup runs when the ID changes and when the component unmounts. Each Effect invocation has its own active variable and controller. A completed request cannot clear another request’s loading state because all updates stay inside its own guarded branch.

The ID stored with the result also protects the brief render before the replacement Effect starts. Without it, the old record may appear under a newly selected ID for a moment.

Why both cancellation and a guard?

Aborting fetch reduces unnecessary work, but an application can also have asynchronous processing or APIs that do not support cancellation. A request-local guard states the UI rule directly: obsolete work must not write into this view.

Cancellation is not a database rollback. If the request performs a mutation, the server might have completed it before the browser aborts. Use server-side idempotency or version checks for writes rather than assuming cleanup undoes them.

Errors and loading states

fetch does not normally reject just because an HTTP response has a 404 or 500 status. Check response.ok before treating the body as successful data. A failed request, an empty result and an in-progress request should have distinct UI states.

Avoid a shared unconditional finally(() => setLoading(false)) when old and new requests overlap. An obsolete request can then clear the current request’s spinner. Keep loading associated with the active request, as in the example.

How to test the race

Use a mock server or controlled request layer that lets you resolve calls independently:

  1. Select Ada and leave that response pending.
  2. Select Grace, then resolve Grace’s response successfully.
  3. Resolve Ada’s older response.
  4. Verify that Grace remains visible and no obsolete loading or error state appears.
  5. Repeat with unmounting and with a failed current request.

Checking only the normal completion order will not expose this bug. Also test development Strict Mode’s setup-cleanup-setup behavior; suppressing the second setup with a flag can hide a missing cleanup rather than fix it.

When to use a data library instead

A framework loader or query cache can coordinate caching, duplicate requests, invalidation and navigation better than separate Effects scattered through the tree. This example is useful for understanding ownership, but it is not a requirement to build your own cache. The same rule applies with a library: responses must be associated with the right query identity.

Interview answer

“An old response can win because network completion order is independent of selection order. I make the Effect depend on the selected ID, clean up obsolete work and guard state writes. I check HTTP errors explicitly and tie loading to the active request. For a larger app, I would use the framework’s data layer while preserving the same identity rules.”

Continue with useEffect pitfalls, React interview questions and the React MCQ bank. Reference: React’s useEffect documentation and MDN AbortController.

More in React

read ✓React · mid

useEffect Pitfalls Interviewers Love to Ask

Stale closures, missing cleanup, fetch races, Strict Mode's double run, infinite loops and effects you don't need, plus how useLayoutEffect differs in timing.

~7 min readread →
read ✓React · easy

Controlled vs Uncontrolled Inputs in React

Compare controlled and uncontrolled React inputs with examples, reset behavior, checkbox handling and fixes for the uncontrolled-to-controlled warning.

~3 min readread →
read ✓React · mid

React Reconciliation & Keys: What Really Happens

Render vs commit, the diffing rules React relies on, why index keys break inputs, how a key resets state, and what Fiber and batching change about updates.

~7 min readread →
esc