Avoiding and Handling Data Fetch Race Conditions in React

January 30, 2022

Race conditions are bugs that happen due to assumptions about either the timing or order of execution of multiple operations that run in parallel. For a mental model, they can be thought of as two or more operations 'racing' to do something to shared data.

They fall under the category of concurrency problems — there is a host of inherent potential problems that result from doing many things at once in the same programme. I like to think of race conditions in the following way:

race condition = concurrency + trigger

Concurrency comes with the inherent risk of a race condition, such as having multiple async function calls update the same piece of state. Add a trigger to that, e.g. network latencies, and we are likely to end up with sneaky bugs in our code.

Race conditions in React

In React (and JavaScript more generally), race conditions are typically introduced by asynchronous work. The majority of apps need to fetch data from some API, and that is done via promises or callbacks. That work gets done in a background thread somewhere, and the promises resolve/callbacks are called when it is done. The potential pitfalls with this are that:

  1. the network request itself might be delayed (e.g. due to poor connectivity) or unexpectedly throw an error
  2. an error might occur during the parsing of the response (i.e. when calling res.json())

What we commonly tend to do is take the data returned by the request and update state with it. Our assumption is that the async requests will resolve in the order that we called them, but due to the potential issues listed above, the assumption does not always hold. Say, for example, that we perform two operations:

  1. A: fetch data A and update state X
  2. B: fetch data B and update state X

Now, both of these operations are updating the same piece of state, and the fetch part of both operations is prone to the above-mentioned issues. If we perform A and then B, we expect the state to reflect the data returned by B (since it was performed last). But what if B resolves before A even though we called it second? Try clicking A and then swiftly clicking B in the simulated example below:

race condition simulation


State: A
Last clicked: A
Assumption met:

The following happens:

  1. B resolves first (since it's faster)
  2. state is updated with B data
  3. A resolves
  4. state is updated with A data

Our assumption would likely be that since B was clicked last, the final state would reflect B data. In reality, the data we see at the end is of the worker that resolved last. And in the real world, we have no guarantees as to the timing in which our requests are resolved and parsed.

Here's the code for the previous example:

export const RaceConditionExample = () => {
  const [data, setData] = React.useState('A');
  const [lastClicked, setLastClicked] = React.useState('A');

  const fetchWithDelay = async (data: string) => {
    if (data === 'A') {
      return new Promise((res) => {
        // A resolves faster
        setTimeout(() => res(data), 3000);
        setLastClicked('A');
      });
    } else {
      return new Promise((res) => {
        setTimeout(() => res(data), 1000);
        setLastClicked('B');
      });
    }
  };

  async function mutate(data: string) {
    const res = await fetchWithDelay(data);
    setData(res as string);
  }

  return (
    <section>
      <button onClick={()=> mutate('A')}>set to A</button>
      <button onClick={()=> mutate('B')}>set to B</button>
      <button
        onClick={()=> {
          setData('A');
          setLastClicked('A');
        }}
      >
        reset
      </button>
      <br />
      State: {data}
      <br />
      Last clicked: {lastClicked} {data === lastClicked ? '' : ''}
    </section>
  );
};

Consequences

Some of the more common issues include:

  • unexpected UI state
  • unintended and incorrect updates to persistent data
  • unhandled exceptions

What tends to make race conditions more nasty is that they are often difficult to spot, reproduce and debug.

Prevention

To prevent the most common types of race conditions in React:

  • avoid sharing global state when possible. The fewer the number of things touching a particular state element, the better
  • use Suspense for data fetching over useEffect if possible
  • when Suspense is not an option, prefer libraries for data fetching over useEffect, such as react-async-hook or react-query — managing and cancelling requests in useEffect requires a fair amount of boilerplate and is error-prone. These libraries take care of race conditions for you
  • test your code by simulating conditions that better reflect the real world, e.g. using network throttling and mimicked delays