Reinventing the wheel

by Bartoval

Stop React Race Conditions Dead in Their Tracks: Four Battle-Tested Solutions

Understanding and Fixing Race Conditions in React Data Fetching

Race conditions are one of those sneaky bugs that can plague React applications, especially when dealing with data fetching. They’re relatively rare in everyday development, but when they do occur, they can be frustrating to debug and fix. In this article, we’ll explore what race conditions are, why they happen in React applications, and how to effectively solve them.

What is a Race Condition?

A race condition occurs when the behavior of a system depends on the sequence or timing of uncontrollable events. In React, race conditions typically happen when multiple asynchronous operations (like API calls) are in progress simultaneously, and their results arrive in an unpredictable order.

The most common scenario is when a component makes a data fetch request, but before that request completes, the component’s state changes (often due to user interaction), triggering another fetch request. When both requests eventually complete, they might update the component’s state in the wrong order, causing unexpected UI behavior.

A Simple Example of a Race Condition

Let’s look at a typical implementation that’s vulnerable to race conditions:

const Page = ({ id }) => {
  const [data, setData] = useState({});

  // Generate URL based on id prop
  const url = `/api/data/${id}`;

  useEffect(() => {
    fetch(url)
      .then((r) => r.json())
      .then((r) => {
        // Save data from fetch request to state
        setData(r);
      });
  }, [url]);

  return (
    <>
      <h2>{data.title}</h2>
      <p>{data.description}</p>
    </>
  );
};

This component fetches data based on an id prop and updates its state when the data arrives. It seems harmless, but here’s what can happen:

  1. The component renders with id="1", triggering a fetch request for /api/data/1
  2. Before this request completes, the user clicks a button that changes id to “2”
  3. This triggers a new fetch request for /api/data/2
  4. Now we have two requests in flight
  5. If the second request completes first, the component shows data for id="2"
  6. But if the first request completes afterward, it overwrites the state with data for id="1", even though the current id is “2”

This creates a confusing UI experience where the displayed content suddenly switches to incorrect data after the user has already navigated away.

Why Does This Happen?

The root causes are:

  1. Promises are asynchronous: When you call fetch(), it returns immediately but completes at some unpredictable future time
  2. Closure behavior: Each fetch callback captures a reference to the same setData function
  3. React lifecycle: Components maintain their state between renders unless explicitly unmounted

Four Ways to Fix Race Conditions

Let’s explore different approaches to solve this problem:

1. Force Re-mounting Components

One solution is to force the component to completely unmount and remount when the id changes, rather than just re-rendering:

// In the parent component
<Page id={page} key={page} />

By using the key prop with a value that changes (the page id), React will completely unmount and recreate the component when the key changes. This means any in-flight requests from the previous component instance are effectively abandoned.

Pros: Simple implementation

Cons: Can impact performance, might cause unexpected side effects with focus management or animations

2. Filter Results Based on Current ID

A more elegant approach is to check if the returned data matches the current ID:

const Page = ({ id }) => {
  const [data, setData] = useState({});
  // Store current ID in a ref so we can access it in async callbacks
  const currentIdRef = useRef(id);

  useEffect(() => {
    // Always update ref to latest ID
    currentIdRef.current = id;

    fetch(`/api/data/${id}`)
      .then((r) => r.json())
      .then((r) => {
        // Only update state if the result matches current ID
        if (currentIdRef.current === r.id) {
          setData(r);
        }
      });
  }, [id]);

  return (
    <>
      <h2>{data.title}</h2>
      <p>{data.description}</p>
    </>
  );
};

This approach uses a ref to keep track of the current ID and only updates the state if the returned data matches that ID.

Pros: No unnecessary re-renders, maintains component state

Cons: Requires the response to include an identifier that can be compared

3. Track Request Relevance with Cleanup

We can use the useEffect cleanup function to track which request is the most recent:

const Page = ({ id }) => {
  const [data, setData] = useState({});

  useEffect(() => {
    // Flag to track if this effect instance is still "active"
    let isActive = true;

    fetch(`/api/data/${id}`)
      .then((r) => r.json())
      .then((r) => {
        // Only update state if this effect is still active
        if (isActive) {
          setData(r);
        }
      });

    // Cleanup function runs before next effect or unmount
    return () => {
      isActive = false;
    };
  }, [id]);

  return (
    <>
      <h2>{data.title}</h2>
      <p>{data.description}</p>
    </>
  );
};

This uses JavaScript closures to track which request is still relevant. When the dependency changes or the component unmounts, the cleanup function sets isActive to false, preventing old requests from updating the state.

Pros: Clean implementation, works without response identifiers

Cons: Relies on understanding JavaScript closures, which can be conceptually challenging

4. Cancel In-Flight Requests with AbortController

Modern browsers support the AbortController API, which allows us to actually cancel fetch requests:

const Page = ({ id }) => {
  const [data, setData] = useState({});

  useEffect(() => {
    // Create a controller for this request
    const controller = new AbortController();

    fetch(`/api/data/${id}`, {
      signal: controller.signal,
    })
      .then((r) => r.json())
      .then((r) => {
        setData(r);
      })
      .catch((error) => {
        // Ignore abort errors
        if (error.name !== "AbortError") {
          // Handle other errors
          console.error(error);
        }
      });

    // Cleanup function cancels the request
    return () => {
      controller.abort();
    };
  }, [id]);

  return (
    <>
      <h2>{data.title}</h2>
      <p>{data.description}</p>
    </>
  );
};

This approach actually cancels previous requests when a new one starts, preventing them from ever completing or updating state.

Pros: Most efficient approach, actually cancels network requests

Cons: Requires error handling for aborted requests

Real-World Example: Building an Auto-Complete Search Component

Let’s build a more complex, real-world example of how race conditions can appear in a typical React application. We’ll create an auto-complete search component that queries an API as the user types - a common pattern that’s particularly susceptible to race conditions.

The Problem

Imagine a search box where users type to find movies. As they type, we want to show matching results. Here’s how this component might be implemented:

const MovieSearch = () => {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    // Don't search on empty query
    if (!query) {
      setResults([]);
      return;
    }

    const searchMovies = async () => {
      setIsLoading(true);
      try {
        // Simulate network latency with random delay (1-3 seconds)
        // In real apps, this would just be the natural API response time
        await new Promise((resolve) =>
          setTimeout(resolve, Math.random() * 2000 + 1000)
        );

        const response = await fetch(
          `https://api.example.com/movies?search=${query}`
        );
        const data = await response.json();
        setResults(data.results);
      } catch (error) {
        console.error("Search failed:", error);
        setResults([]);
      } finally {
        setIsLoading(false);
      }
    };

    searchMovies();
  }, [query]);

  return (
    <div className="movie-search">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search for movies..."
      />

      {isLoading && <div className="loading">Loading...</div>}

      <ul className="results">
        {results.map((movie) => (
          <li key={movie.id}>
            <img src={movie.poster} alt={movie.title} />
            <div>
              <h3>{movie.title}</h3>
              <p>({movie.year})</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

The Race Condition

Here’s the sequence of events that creates the race condition:

  1. User types “star” quickly
  2. API request for “s” is sent
  3. API request for “st” is sent
  4. API request for “sta” is sent
  5. API request for “star” is sent
  6. Due to network conditions, the results for “sta” arrive first
  7. UI displays results for “sta”
  8. Results for “st” arrive and update the UI (incorrect, as user has typed “star”)
  9. Results for “s” arrive and update the UI (very incorrect)
  10. Finally, correct results for “star” arrive and update the UI

This creates a jarring user experience where the search results keep changing unexpectedly, often showing completely irrelevant results before finally settling on the correct ones.

Fixing with AbortController

Here’s how we can fix this with the AbortController approach:

const MovieSearch = () => {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    // Don't search on empty query
    if (!query) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    const signal = controller.signal;

    const searchMovies = async () => {
      setIsLoading(true);
      try {
        // Simulate network latency with random delay
        await new Promise((resolve, reject) => {
          const timeoutId = setTimeout(resolve, Math.random() * 2000 + 1000);

          // If the controller aborts, clear the timeout and reject
          signal.addEventListener("abort", () => {
            clearTimeout(timeoutId);
            reject(new Error("Aborted"));
          });
        });

        // Check if we've been aborted before making the fetch
        if (signal.aborted) throw new Error("Aborted");

        const response = await fetch(
          `https://api.example.com/movies?search=${query}`,
          { signal } // Pass the signal to fetch
        );

        const data = await response.json();

        // Final check before updating state
        if (!signal.aborted) {
          setResults(data.results);
          setIsLoading(false);
        }
      } catch (error) {
        // Only log actual errors (not aborts)
        if (error.name !== "AbortError" && error.message !== "Aborted") {
          console.error("Search failed:", error);
          if (!signal.aborted) {
            setResults([]);
            setIsLoading(false);
          }
        }
      }
    };

    searchMovies();

    // Clean up function will cancel in-flight requests
    return () => {
      controller.abort();
    };
  }, [query]);

  // UI rendering code remains the same
  return (
    <div className="movie-search">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search for movies..."
      />

      {isLoading && <div className="loading">Loading...</div>}

      <ul className="results">
        {results.map((movie) => (
          <li key={movie.id}>
            <img src={movie.poster} alt={movie.title} />
            <div>
              <h3>{movie.title}</h3>
              <p>({movie.year})</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

With this implementation:

  1. Each time the user types a character, the previous request is aborted
  2. Only the most recent request is allowed to complete
  3. The UI only displays results that match what the user has currently typed
  4. We’ve added proper error handling for aborted requests
  5. The artificial delay is properly canceled when we abort the request

Performance Optimization

In a real application, we might want to add a debounce function to prevent sending too many requests as the user types rapidly:

import { useState, useEffect, useCallback } from "react";
import debounce from "lodash.debounce";

const MovieSearch = () => {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  // Debounce the query value to avoid excessive API calls
  const debouncedSetQuery = useCallback(
    debounce((value) => {
      setDebouncedQuery(value);
    }, 300),
    []
  );

  // Handle input changes
  const handleInputChange = (e) => {
    const value = e.target.value;
    setQuery(value);
    debouncedSetQuery(value);
  };

  // Make the API request using the debounced query value
  useEffect(() => {
    if (!debouncedQuery) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    const signal = controller.signal;

    const searchMovies = async () => {
      setIsLoading(true);
      try {
        const response = await fetch(
          `https://api.example.com/movies?search=${debouncedQuery}`,
          { signal }
        );

        const data = await response.json();

        if (!signal.aborted) {
          setResults(data.results);
        }
      } catch (error) {
        if (error.name !== "AbortError") {
          console.error("Search failed:", error);
          if (!signal.aborted) {
            setResults([]);
          }
        }
      } finally {
        if (!signal.aborted) {
          setIsLoading(false);
        }
      }
    };

    searchMovies();

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

  return (
    <div className="movie-search">
      <input
        type="text"
        value={query}
        onChange={handleInputChange}
        placeholder="Search for movies..."
      />

      {isLoading && <div className="loading">Loading...</div>}

      <ul className="results">
        {results.map((movie) => (
          <li key={movie.id}>
            <img src={movie.poster} alt={movie.title} />
            <div>
              <h3>{movie.title}</h3>
              <p>({movie.year})</p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
};

This implementation combines debouncing with the AbortController approach, giving us the best of both worlds:

  1. We reduce the total number of API requests by debouncing user input
  2. We still handle any potential race conditions by aborting outdated requests
  3. The user interface remains responsive throughout the typing experience
  4. Search results always match what the user has typed

By implementing these patterns, we can create a fast, responsive, and bug-free auto-complete experience that handles race conditions gracefully.

Does async/await Change Anything?

It’s worth noting that using async/await syntax instead of promise chains doesn’t change the underlying issue or solutions. Race conditions can still occur with code like this:

useEffect(() => {
  const fetchData = async () => {
    const response = await fetch(`/api/data/${id}`);
    const result = await response.json();
    setData(result);
  };

  fetchData();
}, [id]);

The same solutions we discussed above would still apply, just with slightly modified syntax.

Conclusion

Race conditions in React data fetching can create confusing user experiences, but they’re solvable with several different approaches. Depending on your specific requirements, you might choose:

  1. Force re-mounting with the key prop for a quick solution
  2. Filter results based on the current ID for a clean implementation
  3. Track request relevance with useEffect cleanup for a more elegant approach
  4. Cancel in-flight requests with AbortController for the most efficient solution

Understanding how these race conditions occur and having multiple strategies to address them will make your React applications more robust and provide a better user experience.