DEV Community

Cover image for Avoiding Race Conditions when Fetching Data with React Hooks
Nick Scialli (he/him)
Nick Scialli (he/him)

Posted on

Avoiding Race Conditions when Fetching Data with React Hooks

The React useEffect hook is great for performing side effects in functional components. One common example of this is fetching data. If you're not careful to clean up your effect, however, you can end up with a race condition! In this post, we'll make sure we appropriately clean up our effects so we don't have this race condition issue.

Setup

In our example app, we are going to fake-load people's profile data when their names are clicked. To help visualize the race condition, we'll create a fakeFetch function that implements a random delay between 0 and 5 seconds.

const fakeFetch = person => {
  return new Promise(res => {
    setTimeout(() => res(`${person}'s data`), Math.random() * 5000);
  });
};
Enter fullscreen mode Exit fullscreen mode

Initial Implementation

Our initial implementation will use buttons to set the current profile. We reach for the useState hook to implement this, maintaining the following states:

  • person, the person selected by the user
  • data, the data loaded from our fake fetch based on the selected person
  • loading, whether data is currently being loaded

We additional use the useEffect hook, which performs our fake fetch whenever person changes.

import React, { Fragment, useState, useEffect } from 'react';

const fakeFetch = person => {
  return new Promise(res => {
    setTimeout(() => res(`${person}'s data`), Math.random() * 5000);
  });
};

const App = () => {
  const [data, setData] = useState('');
  const [loading, setLoading] = useState(false);
  const [person, setPerson] = useState(null);

  useEffect(() => {
    setLoading(true);
    fakeFetch(person).then(data => {
      setData(data);
      setLoading(false);
    });
  }, [person]);

  return (
    <Fragment>
      <button onClick={() => setPerson('Nick')}>Nick's Profile</button>
      <button onClick={() => setPerson('Deb')}>Deb's Profile</button>
      <button onClick={() => setPerson('Joe')}>Joe's Profile</button>
      {person && (
        <Fragment>
          <h1>{person}</h1>
          <p>{loading ? 'Loading...' : data}</p>
        </Fragment>
      )}
    </Fragment>
  );
};
export default App;
Enter fullscreen mode Exit fullscreen mode

If we run our app and click one of the buttons, our fake fetch loads data as expected.

Hitting the race condition

The trouble comes when we start switching between people in quick succession. Given the fact that our fake fetch has a random delay, we soon find that our fetch results may be returned out of order. Additionally, our selected profile and loaded data can be out of sync. That's a bad look!

Data out of sync

What's happening here is relatively intuitive: setData(data) within the useEffect hook is only called after the fakeFetch promise is resolved. Whichever promise resolves last will call setData last, regardless of which button was actually called last.

Canceling previous fetches

We can fix this race condition by "canceling" the setData call for any clicks that aren't most recent. We do this by creating a boolean variable scoped within the useEffect hook and returning a clean-up function from the useEffect hook that sets this boolean "canceled" variable to true. When the promise resolves, setData will only be called if the "canceled" variable is false.

If that description was a bit confusing, the following code sample of the useEffect hook should help.

useEffect(() => {
  let canceled = false;

  setLoading(true);
  fakeFetch(person).then(data => {
    if (!canceled) {
      setData(data);
      setLoading(false);
    }
  });

  return () => (canceled = true);
}, [person]);
Enter fullscreen mode Exit fullscreen mode

Even if a previous button click's fakeFetch promise resolves later, its canceled variable will be set to true and setData(data) will not be executed!

Let's take a look at how our new app functions:

Data in sync

Perfect—No matter how many times we click different buttons, we will always only see data associated with the last button click.

Full code

The full code from this blog post can be found below:

import React, { Fragment, useState, useEffect } from 'react';

const fakeFetch = person => {
  return new Promise(res => {
    setTimeout(() => res(`${person}'s data`), Math.random() * 5000);
  });
};

const App = () => {
  const [data, setData] = useState('');
  const [loading, setLoading] = useState(false);
  const [person, setPerson] = useState(null);

  useEffect(() => {
    let canceled = false;

    setLoading(true);
    fakeFetch(person).then(data => {
      if (!canceled) {
        setData(data);
        setLoading(false);
      }
    });

    return () => (canceled = true);
  }, [person]);

  return (
    <Fragment>
      <button onClick={() => setPerson('Nick')}>Nick's Profile</button>
      <button onClick={() => setPerson('Deb')}>Deb's Profile</button>
      <button onClick={() => setPerson('Joe')}>Joe's Profile</button>
      {person && (
        <Fragment>
          <h1>{person}</h1>
          <p>{loading ? 'Loading...' : data}</p>
        </Fragment>
      )}
    </Fragment>
  );
};
export default App;
Enter fullscreen mode Exit fullscreen mode

Discussion (15)

Collapse
kerimdzhanov profile image
Dan Kerimdzhanov

Great article. Thanks for the writing, Nick!

It's also a good idea to abort related HTTP requests when canceling.

You can implement it using AbortController and AbortSignal if you're using Fetch API, and XMLHttpRequest#abort if you're using XMLHttpRequest.

Collapse
smortimerk profile image
Seán Kelleher

One issue that I think is still present in the code is the fact that every tab click is kicking off a new fetch. Instead of only storing the result of a single fetch, you might consider instead saving the results of all (or a subset of all) requests that have completed, and use the currently selected person/tab to index the list of fetched data. I’m more familiar with Vue than React, but the following is a stab at this (untested):

const App = () => {
  const [person, setPerson] = useState(null);
  const [people, setPeople] = useState({});

  useEffect(() => {
    if (!people[person]) {
      setPeople({...people, [person]: {loaded: false, data: ''}});

      fakeFetch(person).then(data => {
        setPeople({...people, [person]: {loaded: true, data}});
      });
    }
  }, [person]);

  return (
    <Fragment>
      <button onClick={() => setPerson('Nick')}>Nick's Profile</button>
      <button onClick={() => setPerson('Deb')}>Deb's Profile</button>
      <button onClick={() => setPerson('Joe')}>Joe's Profile</button>
      {person && (
        <Fragment>
          <h1>{person}</h1>
          <p>{!people[person] || people[person].loading ? 'Loading...' : people[person].data}</p>
        </Fragment>
      )}
    </Fragment>
  );
};
export default App;

This has the added benefit that your navigation state is better decoupled from your object state, meaning that clicking around in your application doesn’t have the potential to leave your data in an inconsistent state if it gets more complicated.

Collapse
nas5w profile image
Nick Scialli (he/him) Author • Edited

It sounds like this concern would be more appropriately managed through cache headers from the backend. In this implementation, it seems the frontend is deciding that the initial data fetch is good for as long as the app is open.

Collapse
talha131 profile image
Talha Mansoor

it seems the frontend is deciding that the initial data fetch is good for as long as the app is open.

It is a perfect point.

If we ignore it for a moment please, then my question is, is it possible to leverage useMemo hook for caching data in the example? How will that work?

Collapse
talha131 profile image
Talha Mansoor

Hi @nas5w . Thanks for writing this article. Great use of the useEffect cleaner.

I think another way to resolve this issue is to keep the buttons disabled until the promise is resolved or rejected. This will make for a less confusing UX. User will not be able to click the conflicting buttons to being with.

Collapse
nas5w profile image
Nick Scialli (he/him) Author

Thanks! I think in this example, however, you wouldn’t want to prevent someone from navigating to another user’s profile just because a fetch request hasn’t returned. The user should be able to freely navigate the application regardless of whether the fetch request has resolved.

Collapse
n1ru4l profile image
Laurin Quast • Edited

This was popping up on my feed and I wanted to share my higher-level abstraction I built using a generator function API.

If you have many useEffect hook usages or complex useEffect logic, all the checking on whether the task should be canceled can become quite tedious.

Sample Usage:

const MyComponent = ({ filter }) => {
  const [data, setData] = React.useState(null);

  useAsyncEffect(
    function*() {
      const data = yield fetch("/data?filter=" + filter, {
        signal: controller.signal
      }).then(res => res.json());

      setData(data);
    },
    [filter]
  );

  return <Renderer data={data} />;
};

In case the effect is canceled (due to unmount or dependency array change), the Promise that was yield-ed will be ignored and the generator won't be invoked with the resolved value.

The API also allows registering optional cancelation handlers, which is quite handy for canceling in-flight requests.

const MyComponent = ({ filter }) => {
  const [data, setData] = React.useState(null);

  useAsyncEffect(
    function*(onCancel) {
      const controller = new AbortController();
      onCancel(() => controller.abort());

      const data = yield fetch("/data?filter=" + filter, {
        signal: controller.signal
      }).then(res => res.json());

      setData(data);
    },
    [filter]
  );

  return <Renderer data={data} />;
};

The library can be found here: github.com/n1ru4l/use-async-effect and I also wrote a blog article about it: dev.to/n1ru4l/homebrew-react-hooks...

I am currently also designing an API for the V2 which will also support Node.js style callbacks beside promises.

I hope it can help some of you ☺️

Collapse
intrnl profile image
intrnl

I'd also recommend using AbortController so that the fetch request is actually cancelled!

Collapse
vercetti11 profile image
Octavio Vercetti • Edited

Does not work for components that fetch based on props.
But using the AbortController object makes it work as intended.

import { useEffect, useRef, useReducer, useCallback } from "react";

function asyncReducer(state, action) {
  switch (action.type) {
    case "pending": {
      return { status: "pending", data: null, error: null };
    }
    case "resolved": {
      return { status: "resolved", data: action.data, error: null };
    }
    case "rejected": {
      return { status: "rejected", data: null, error: action.error };
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`);
    }
  }
}

export const useFetch = url => {
  const isCurrent = useRef(false);
  const [state, dispatch] = useReducer(asyncReducer, {
    status: "idle",
    data: null,
    error: null,
  });

  useEffect(() => {
    isCurrent.current = true;
    return () => {
      // called when the component is going to unmount
      isCurrent.current = false;
    };
  }, []);

  const fetchURL = useCallback(
    controller => {
      dispatch({ type: "pending" });
      fetch(url, {
        signal: controller.signal,
      })
        .then(res => res.json())
        .then(data => {
          if (!isCurrent.current) return;
          dispatch({ type: "resolved", data });
        })
        .catch(error => {
          if (!isCurrent.current) return;
          dispatch({ type: "rejected", error });
        });
    },
    [url]
  );

  useEffect(() => {
    let controller = new AbortController();
    fetchURL(controller);
    return () => controller.abort();
  }, [fetchURL]);

  return { state, fetchURL };
};

Enter fullscreen mode Exit fullscreen mode
Collapse
dimpiax profile image
Dmytro Pylypenko

It's not a race condition, as here, and in React, is nothing about threads.
Reusing such phrases in the wrong context – investment for making people to understand things in messed way.

Collapse
jsamr profile image
Jules Sam. Randolph

Race conditions can occur especially in logic circuits, multithreaded, or distributed software programs.

Moreover, the expression was coined by David A. Huffman in the area of logic circuits, so it would also be historically irrelevant to restrict its meaning to multithreading.

Source: en.wikipedia.org/wiki/Race_condition

Collapse
dimpiax profile image
Dmytro Pylypenko

Right! Thanks for the link :)

Collapse
n1ru4l profile image
Laurin Quast

Why would you not call this a race condition? Why would race conditions not occur in an event loop environment?

Collapse
nas5w profile image
Nick Scialli (he/him) Author

I have heard some people make the argument that race conditions can only happen in multithreaded environments by definition. I don’t really agree (or think it matters) as you’ll find plenty of people out there who would contend that multithreading is not required to be a race condition. At any rate, I’m not terribly interested in calling it a “condition in which there is a race” just to avoid what some might consider a reserved phrase.

Collapse
tomerl101 profile image
Tomer

What about wrapping the fetch request with debounce function?