Back to Blog
Lesson 23 of the Advanced React: Performance, Architecture & Patterns course
ReactJune 28, 20264 min read

Handling Race Conditions in React: A Pro Guide

Learn to master race conditions in React by using cleanup functions, ignore flags, and AbortController to ensure your app state stays consistent.

reactjavascriptfrontend

Previously in this course, we explored optimistic UI updates to keep our applications feeling responsive. However, optimistic updates are only one half of the reliability equation. When we deal with asynchronous data, we often face the "last one in, first one out" problem—where a stale request resolves after a newer one, overwriting current data with outdated information.

In React, this is a classic Race Condition. If you aren't explicitly handling the lifecycle of your useEffect requests, your UI will eventually show inconsistent state.

The Nature of the Race

A race condition occurs in React when multiple async operations are triggered by a component, but they return in a non-deterministic order.

Imagine a user searching for "React," then quickly typing "Redux." If the "React" network request is slower than the "Redux" one, your component might render the "React" results after the "Redux" results have already been processed.

StrategyMechanismBest For
Ignore FlagBoolean local variableSimple async logic
Cleanup FunctionuseEffect returnGeneral side-effect cleanup
AbortControllerNative browser APIProduction-grade network requests

The "Ignore Flag" Pattern

The simplest way to prevent a state update from a stale effect is to track whether the effect is still "current."

JAVASCRIPT
useEffect(() => {
  let active = true;

  const fetchData = async () => {
    const data = await api.get(CE9178">'/search', { query });
    if (active) {
      setResults(data);
    }
  };

  fetchData();

  return () => {
    active = false; // The cleanup function marks this effect as stale
  };
}, [query]);

When query changes, React runs the cleanup function from the previous render. By setting active = false, we ensure that even if the promise eventually resolves, the setResults call is ignored.

Native Cancellation with AbortController

While the ignore flag prevents the state update, it doesn't stop the network request from consuming bandwidth. For production apps, you should use the browser's native AbortController.

This is the standard way to actually kill an in-flight fetch request.

JAVASCRIPT
useEffect(() => {
  const controller = new AbortController();

  const fetchData = async () => {
    try {
      const response = await fetch(CE9178">`/api/search?q=${query}`, {
        signal: controller.signal,
      });
      const data = await response.json();
      setResults(data);
    } catch (err) {
      if (err.name !== CE9178">'AbortError') {
        // Handle actual network errors
        console.error(err);
      }
    }
  };

  fetchData();

  return () => controller.abort();
}, [query]);

Hands-on Exercise: Implementing Request Cancellation

In our running project, we have a UserDashboard component that fetches user profile data based on a userId prop. Currently, if a user clicks through several profiles rapidly, the UI flickers between different user names.

  1. Open the UserDashboard component.
  2. Identify the useEffect responsible for the data fetch.
  3. Refactor it to use AbortController to cancel the previous request whenever userId changes.
  4. Verify the fix by throttling your network speed in the Chrome DevTools "Network" tab to "Slow 3G" and clicking through profiles rapidly.

Common Pitfalls

  • Forgetting the Cleanup: The most frequent mistake is omitting the return () => ... block. If you don't clean up, your state setters will trigger warnings in development and cause bugs in production.
  • Ignoring AbortErrors: When you call controller.abort(), the fetch promise rejects with an AbortError. You must catch this error specifically; otherwise, your global error handlers will report false positives.
  • Over-reliance on Local State: Sometimes, these race conditions are a sign that you should be using a data-fetching library like TanStack Query. Libraries like this handle deduplication and race conditions internally, which we discussed in our guide on advanced cache invalidation.

Recap

Race conditions are inevitable when building asynchronous UIs. By leveraging the component lifecycle:

  1. Cleanup functions are your primary tool for signaling that an effect is no longer relevant.
  2. Ignore flags are useful for simple state synchronization.
  3. AbortController is the professional standard for network-level cancellation, saving client resources and ensuring UI consistency.

Always ensure your effects are self-contained and respect the lifecycle of the component. When you move beyond simple fetches, rely on battle-tested abstractions to manage the state machine for you.

Up next: We'll move into Server-Client State Synchronization, where we'll build a layer to reconcile server responses with optimistic UI states.

Similar Posts