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

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). Components can perform side-effects in event handlers, but fetching API data requires the useEffect hook.

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. So, even though we changed the url, the List didn’t re-render and make 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.

Now 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", but 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.

Our app now correctly displays only words starting with “aa”, handling out-of-order responses using the Fetch-and-ignore idiom.

  1. Effect #1 starts a request for “a”.
  2. List re-renders, setting ignore = true for effect #1 and starting a request for “aa” in effect #2.
  3. The “aa” response arrives first (ignore = false), so List displays it.
  4. The “a” response arrives later (ignore = true), so List discards it.

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.

Custom hooks also help with advanced features like data caching and future-proofing. In the future, React’s Suspense will handle data fetching without useEffect, making refactoring easier.

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.