p3-insta485-clientside

Data Fetching in React with useEffect

This tutorial will explain with a comprehensive example how to fetch data in React with useEffect while avoiding common problems.

In particular, we’ll explain why a dependency array and a cleanup function are required to avoid race conditions.

Prerequisites

This tutorial assumes some prerequisite knowledge.

  1. EECS 485 React tutorial explains React basics.
  2. Keeping Components Pure explains why React components avoid side-effects.
  3. Synchronizing with Effects explains how to add intentional side-effects.

TL;DR

Use an accurate dependency array every time you include useEffect. Use the Fetch-and-ignore idiom (our term) when your React app makes a REST API request and changes its own state in response. Also, use custom hooks to extract common logic. The Fetch-and-ignore idiom avoids race conditions using these techniques. Read the rest of this tutorial to learn why.

// Simplified example of a component that fetches data in `useEffect` without a custom hook.
function Example({ url }) {
  useEffect(() => {
    let ignoreStaleRequests; // Boolean flag for ignoring requests

    fetch(url)
      .then((response) => {
        return response.json();
      })
      .then((data) => {
        if (ignoreStaleRequests) {
          // If the flag has been set by the cleanup function, exit early.
          return;
        }
        // Do stuff with the fetched data.
      }
    });

    return () => {
      // Cleanup function: set the boolean flag to true when the component unmounts.
      ignoreStaleRequests = true;
    };
  }, [url]);  // Dependency array
}

Introduction

A React component can often perform side-effects in an event handler. A conspicuous exception is a component that immediately displays data fetched from an API. In this case, we can use the React hook useEffect.

Component rendering should be free of side-effects, that is, pure. Rendering should be nothing more than computing an output (JSX) from an input (props, state, and context).

Data fetching is a side-effect because it reads or writes state outside of the component, for example by calling a REST API. Furthermore, after fetching data, you’ll likely want to update the component’s state and re-render the component. If a component changes its own state while rendering, its rendering is not pure. Side-effects are impossible to avoid, but we want to avoid them during rendering.

The React hook useEffect implements a side-effect, safely avoiding the side-effect during rendering. However, useEffect is complex, and it poses a serious risk of making your code harder to understand and less performant. For this reason, you might not need an effect, and you should consider carefully before you decide to use useEffect.

This tutorial shows how to safely fetch data using useEffect. It illustrates the best practices of a dependency array and a cleanup function, and explains why they are needed.

Example

To demonstrate data fetching, we’ll use a small example of an autocomplete widget. When you request "a" from the mock API, you should get back words that start with "a", and when you request "aa", you should get back words that start with "aa". The final result should look something like this:

An App component initially renders a List that requests results from /api/?q=a. Then App will re-render and trigger its List to re-render as well, this time set to request results from /api/?q=aa. We’ll implement the List component in different ways throughout the rest of this tutorial.

// App is the root component for this app.
export default function App() {
  // App will render a List component with a specific URL provided from the App's state.
  // It'll start out as /api/?q=a.
  const [url, setUrl] = useState("/api/?q=a");

  // After App renders initially, simulate a user typing a second "a," changing the autocomplete
  // REST API request to /api/?q=aa. App will re-render and List will also re-render with the new
  // url.
  useEffect(() => {
    setUrl("/api/?q=aa");
  }, []);

  return (
    <div className="App">
      <h1>Request to {url}</h1>
      <List url={url} />
    </div>
  );
}

Mock fetch

During its lifetime, the App component will render List two times, sending two different requests with fetch. In a perfect world, the first response would arrive first. In a real network, the two responses could arrive out of order.

We’ll replicate out-of-order responses by replacing fetch with mockFetchSwap. When mockFetchSwap is called twice, it swaps the order of the responses.

Note: You don’t need to understand this code to understand Problem #1; just make sure to come back to it when you read about Problem #2.

// mockFetch will look up URLs and return hardcoded responses
const DATA = {
  "/api/?q=a": JSON.stringify(["aardvark", "aardwolf", "abacus"]),
  "/api/?q=aa": JSON.stringify(["aardvark", "aardwolf"])
};

// SWAP function will manually resolve every other request
let SWAP = null;

function mockFetch(url) {
  // Return Promise that resolves to JSON Response
  //
  // Note: Promise handlers are always asynchronous, even when a Promise is
  // immediately resolved.  https://javascript.info/microtask-queue
  console.assert(url in DATA);
  const response = new Response(DATA[url]);
  return Promise.resolve(response);
}

export default function mockFetchSwap(url) {
  if (SWAP) {
    // Resolve the pending promise from a previous call to this function by
    // calling SWAP().  Then return the value for the current fetch.
    SWAP();
    SWAP = null;
    return mockFetch(url);
  } else {
    // Return a Promise chain that resolves after SWAP() is called.
    return new Promise((resolve) => {
      SWAP = resolve;
    }).then(() => {
      return mockFetch(url);
    });
  }
}

In the next section, we’ll try implementing the List component.

Problem #1: empty dependency array

For our first attempt, when List sends a request, we’ll use useEffect with an empty dependency array.

The App component will first render a List component with its url prop set to "a". The List will make a request for the query "a", and get back a pending Promise. Then the App will re-render and trigger the List to re-render as well with its url prop set to "aa". We expect to see a list of words that start with “aa”. Let’s see what actually happens:

Note: Editing the code in the sandbox will trigger a re-render, which may affect the results. Don’t edit it until after you’ve observed the initial results.

The list of results is empty! Because the dependency array is empty, the effect only ran once, the first time the List rendered to the screen. When url changed, the List failed to re-run its effect, which would have otherwise made a request to the new url.

Pitfall: An empty dependency array can result in failure to re-render.

If there were no dependency array at all (distinct from an empty array), the effect would run every single time the component renders. The effect updates the List’s state, and updating state triggers a re-render, so running this effect on every render would cause an infinite loop. The result would be a punishingly slow user experience.

Pitfall: A missing dependency array can result in infinite re-render.

Always use a dependency array with useEffect unless you’re absolutely certain that you want the effect to run on every render. ESLint will tell you if the values in the dependency array are wrong. You can read about removing effect dependencies in the docs if you disagree with the linter.

In the next section, we’ll fix our missing output problem.

Problem #2: missing cleanup function

Now let’s fix the dependency array by adding url to it, causing the effect to run both after the first render and again every time the List renders with a different url. Otherwise, we’ll leave the code the same.

Note: Editing the code in the sandbox will trigger a re-render, which may affect the results. Don’t edit it until after you’ve observed the initial results.

This is better, since we’re getting results, but the results are wrong! The second request was for the query "aa", but one of the words in the results is “abacus,” which does not begin with “aa.”

The List made a request for "a" followed by a request for "aa". Our mock fetch reversed the order of the responses, simulating out-of-order network conditions.

  1. The first response to arrive was for the second request "aa". The List displays the results for "aa".

  2. The second response to arrive was for first request "a". The List displays the results for "a".

We have a race condition: the behavior of our component depends on the order of network responses instead of the order of client interactions!

Race conditions are not unique to React. Any client-side dynamic pages app that synchronizes with a REST API is vulnerable to race conditions.

Solution

The solution to this race condition is a cleanup function. The cleanup function will discard results from stale requests.

A cleanup function is called every time the component unmounts or re-renders. In the cleanup function, we can add logic to ignore the response to a stale request.

Finally, our app show correct results! The results are only the words that begin with "aa".

As before, the List made a request for "a" followed by a request for "aa". Our mock fetch reversed the order of the responses, simulating out-of-order network conditions.

  1. A request for "a" starts in effect #1.
  2. List re-renders, setting the ignore variable in effect #1 to true and starting a request for "aa" in effect #2.
  3. The first response to arrive was for the second request "aa" in effect #2. The ignore variable is false. The List displays the results for "aa".
  4. The second response to arrive was for the first request "a" in effect #1. The ignore variable is true. The List discards the results for "a".

We like to call this the Fetch-and-ignore idiom.

Pitfall: Failure to implement the Fetch-and-ignore idiom may result in a race condition.

An alternative to a Boolean variable is AbortController, which cancels a request instead of ignoring the results.

Problem #3: duplicate code

Every time a component uses fetch, it must correctly use a dependency list and correctly implement the fetch-and-ignore idiom. The result is duplicate code.

Avoid duplicate code by writing a custom hook for data fetching so that you don’t need to repeat all of this boilerplate every time you fetch data.

Another motivation for a custom hook is if you want to augment the hook with more complicated operations like data caching.

A third motivation for a custom hook is in the future, data fetching on the client in React won’t even involve useEffect: it will involve a feature called Suspense. Using a custom hook will make it easier to refactor code that previously fetched data using this method so that it uses the (not yet production-ready) built-in React method instead.

The React Docs have an example of using a custom hook for data fetching!

Summary

Data fetching is an elaborate problem. The current React mechanism for data fetching is the useEffect hook. The useEffect hook has several fatal pitfalls, including missing results, infinite re-render, and race conditions.

Avoid the pitfalls of useEffect by including a dependency array and a cleanup function when you fetch data with useEffect.

Further reading

We found these documents helpful during the writing of this tutorial.

  1. You Might Not Need an Effect in the React docs
  2. Lifecycle of Reactive Effects in the React docs
  3. Separating Events from Effects in the React docs
  4. Removing Effect Dependencies in the React docs
  5. Reusing Logic with Custom Hooks in the React docs
  6. A Complete Guide to useEffect by Dan Abramov

Acknowledgments

Original document written by Noah Weingarden nwein@umich.edu and Andrew DeOrio awdeorio@umich.edu.

This document is licensed under a Creative Commons Attribution-NonCommercial 4.0 License. You’re free to copy and share this document, but not to sell it. You may not share source code provided with this document.