React pitfalls 2: Avoid stale state when fetching data in useEffect

There are many components that show data loaded from an API. Typically, we load the data first and then set the UI state to display the data. This sounds easy, but there is a pitfall that can lead to inconsistent UI state. This is a short post on the problem that leads to the stale state and how to fix this.

The problem of rogue state updates

To load data from an API with React hooks, we call the API inside a useEffect hook and then call setState once we received a response to display the result. In code, this can look something like this:

function ProductList () {
  const [products, setProducts] = useState<Product[]>
  
  useEffect(() => {
    fetch("https://someapi.com/products")
      .then(result => setProducts(result.json()))
  }, [])

  return (
    // render product list
  )
}

The code above looks innocent enough. However, if you use code like this, you’ll soon find the occasional error in your browser console, saying something like “Can't perform a React state update on an unmounted component”. This error is harmless, but it gets worse if we pass parameters to the API call:

function ProductList ({category}: {category: string}) {
  const [products, setProducts] = useState<Product[]>
  
  useEffect(() => {
    fetch(`https://someapi.com/products?category=${category}`)
      .then(result => setProducts(result.json()))
  }, [category])

  return (
    // render product list
  )
}

This code can now result in an inconsistent state, where the user selects some category, but we display products from a different category instead.

The issue here is, that we are setting state after calling an asynchronous function. By the time the promise resolves, the component may have dismounted or parameters may have changed that make the state stale. In the second example, if we update the prop category , there is a race between the effects. In the worst case, we trigger fetch with category A, then update category to B while the promise for A hasn’t resolved yet. If the promise for B resolves before the promise for A, we end up with setting state with the result for category A, even though we changed category to B already. Here is the sequence of events:

  1. Trigger fetch for category A
  2. Update prop to category B
  3. Trigger fetch for category B
  4. Receive a response for category B and update the product list
  5. Receive a response for category A and update the product list

We’re now left with an inconsistent UI state, where the category selector says category B, but we show the products from category A.

The solution

To fix this issue, we need to check that the conditions for setting the new state still apply after the asynchronous function returns. In this case, we need to check that the component hasn’t been unmounted yet and that the category hasn’t changed since we made the request. In React, we can return a cleanup function from the useEffect callback that will be called before the next execution of the effect and before the component dismounts. Using this cleanup function, one solution could look like this:

function ProductList ({category}: {category: string}) {
  const [products, setProducts] = useState<Product[]>
  
  useEffect(() => {
    let isCancelled = false

    fetch(`https://someapi.com/products?category=${category}`)
      .then(result => {
        if (!isCancelled) setProducts(result.json())
      })

    return () => {
      isCancelled = true
    }
  }, [category])

  return (
    // render product list
  )
}

We now keep track of whether the effect has been cancelled. An effect gets cancelled every time before we rerun the effect for a different category or before the component unmounts. Before we update the product list, we check if the effect has been cancelled and only update the products if it hasn’t.


Generally, as a rule of thumb, whenever we set state after an asynchronous operation in an effect, we need to check if the conditions for setting the state are still met.

© 2022 Julian Vossen