DEV Community

Sophia Brandt
Sophia Brandt

Posted on • Originally published at rockyourcode.com on

Avoid Memory Leak With React SetState On An Unmounted Component

Raise your hand ✋, if you've seen this error in your React application:

Warning: Can't call setState (or forceUpdate) on
an unmounted component. This is a no-op, but it
indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous
tasks in the componentWillUnmount method.
Enter fullscreen mode Exit fullscreen mode

The Problem

This error often happens when you make an asynchronous request for data, but the component unmounts. For example, some logic in your app tells React to navigate away from the component.

You still have a pending request for remote data, but when the data arrives and modifies the component's state, the app already renders a different component.

From the React blog:

The “setState warning” exists to help you catch bugs, because calling setState() on an unmounted component is an indication that your app/component has somehow failed to clean up properly. Specifically, calling setState() in an unmounted component means that your app is still holding a reference to the component after the component has been unmounted - which often indicates a memory leak!

In this post I'll show some possible workarounds for avoiding memory leaks with data fetching.

Why Is This Happening?

When you fetch data, you make an asynchronous request. You normally do this by using a Promised-based API, for example, the browser-native fetch.

Example: Call to an API with fetch (Promise-based)

function App() {
  const initialState = {
    isLoading: false,
    isError: false,
    loadedData: [],
  }

  const [state, setState] = React.useState(initialState)

  React.useEffect(() => {
    const fetchData = () => {
      setState(prevState => ({ ...prevState, isLoading: true }))

      fetch('https://ghibliapi.herokuapp.com/people')
        .then(response => response.json())
        .then(jsonResponse => {
          setState(prevState => {
            return {
              ...prevState,
              isLoading: false,
              loadedData: [...jsonResponse],
            }
          })
        })
        .catch(_err => {
          setState(prevState => {
            return { ...prevState, isLoading: false, isError: true }
          })
        })
    }

    // calling the function starts the process of sending ahd
    // storing the data fetching request
    fetchData()
  }, [])

  return <JSX here />
}
Enter fullscreen mode Exit fullscreen mode

You could re-write the data-fetching to use async/await, but that's still a JavaScript Promise under the hood.

JavaScript is single-threaded, so you can't avoid "parking" your code when you do something asynchronous. And that's why you either need event listeners, callbacks, promises, or async/await.

The problem is that you can't cancel a Promise.

Now, your app might change the view, but the promise isn't fulfilled yet. You can't abort the data fetching process after you've started it.

Thus, the above error happens.

Typical Solutions Offered by Internet Searches

  1. Use a third-party library like bluebird or axios.

    Problem: yet another dependency in your project (but the API is mostly easier than rolling your own)

  2. Use Observables

    Problem: you've now introduced another level of complexity

  3. Track the state of your component with isMounted

    Problem: it's an anti-pattern

  4. Create Your Own Cancellation Method

    Problem: it introduces another wrapper around Promises

  5. Use XMLHttpRequest

    Problem: The code is slightly more verbose than with fetch, but you can easily cancel a network request

Let's look at some of the suggestions:

Keep Track of Mounted State

The following workaround gets recommended by popular React authors like Robin Wieruch or Dan Abramov.

Those developers are surely much more smarter than I when it comes to React.

They describe the solution as a stopgap approach. It's not perfect.

function App() {
  const initialState = {
    isLoading: false,
    isError: false,
    loadedData: [],
  }

  const [state, setState] = React.useState(initialState)

  React.useEffect(() => {
    // we have to keep track if our component is mounted
    let isMounted = true

    const fetchData = () => {
      // set the state to "Loading" when we start the process
      setState(prevState => ({ ...prevState, isLoading: true }))

      // native browser-based Fetch API
      // fetch is promised-based
      fetch('https://ghibliapi.herokuapp.com/people')
        // we have to parse the response
        .then(response => response.json())
        // then we have to make sure that we only manipulate
        // the state if the component is mounted
        .then(jsonResponse => {
          if (isMounted) {
            setState(prevState => {
              return {
                ...prevState,
                isLoading: false,
                loadedData: [...jsonResponse],
              }
            })
          }
        })
        // catch takes care of the error state
        // but it only changes statte, if the component
        // is mounted
        .catch(_err => {
          if (isMounted) {
            setState(prevState => {
              return { ...prevState, isLoading: false, isError: true }
            })
          }
        })
    }

    // calling the function starts the process of sending ahd
    // storing the data fetching request
    fetchData()

    // the cleanup function toggles the variable where we keep track
    // if the component is mounted
    // note that this doesn't cancel the fetch request
    // it only hinders the app from setting state (see above)
    return () => {
      isMounted = false
    }
  }, [])

  return <JSX here />
}
Enter fullscreen mode Exit fullscreen mode

(Here's a CodeSandBox link, if you're interested.)

Strictly speaking, you don't cancel your data fetching request. The workaround checks if the component is mounted. It avoids invoking setState if the component is not mounted.

But the network request is still active.

Create Your Own Cancellation Method

The above-mentioned blog post introduces a wrapper around a Promise:

const cancelablePromise = makeCancelable(
  new Promise(r => component.setState({...}))
);

cancelablePromise
  .promise
  .then(() => console.log('resolved'))
  .catch((reason) => console.log('isCanceled', reason.isCanceled));

cancelablePromise.cancel(); // Cancel the promise
Enter fullscreen mode Exit fullscreen mode
const makeCancelable = promise => {
  let hasCanceled_ = false

  const wrappedPromise = new Promise((resolve, reject) => {
    promise.then(
      val => (hasCanceled_ ? reject({ isCanceled: true }) : resolve(val)),
      error => (hasCanceled_ ? reject({ isCanceled: true }) : reject(error))
    )
  })

  return {
    promise: wrappedPromise,
    cancel() {
      hasCanceled_ = true
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, you could introduce a cancellation method around XMLHttpRequest.

Axios uses a similar approach with a cancellation token.

Here's the code from StackOverflow:

function getWithCancel(url, token) { // the token is for cancellation
   var xhr = new XMLHttpRequest;
   xhr.open("GET", url);
   return new Promise(function(resolve, reject) {
      xhr.onload = function() { resolve(xhr.responseText); });
      token.cancel = function() {  // SPECIFY CANCELLATION
          xhr.abort(); // abort request
          reject(new Error("Cancelled")); // reject the promise
      };
      xhr.onerror = reject;
   });
};

// now you can setup the cancellation
var token = {};
var promise = getWithCancel("/someUrl", token);

// later we want to abort the promise:
token.cancel();
Enter fullscreen mode Exit fullscreen mode

Here's a CodeSandBox example.

Both solutions introduce a new helper function. The second one already points us into the direction of XMLHttpRequest.

Low-Level API with XMLHttpRequest

The StackOverflow code wraps your API call into a Promise around XMLHttpRequest. It also adds a cancellation token.

Why not use XMLHttpRequest itself?

Sure, it's not as readable as the browser-native fetch. But we've already established that we must add extra code to cancel a promise.

XMLHttpRequest allows us to abort a request without using promises. Here's a simple implementation with useEffect.

The useEffect function cleans up the request with abort.

function App() {
  const initialState = {
    isLoading: false,
    isError: false,
    loadedData: [],
  }

  const [state, setState] = React.useState(initialState)

  React.useEffect(() => {
    // we have to create an XMLHTTpRequest opject
    let request = new XMLHttpRequest()
    // we define the responseType
    // that makes it easier to parse the response later
    request.responseType = 'json'

    const fetchData = () => {
      // start the data fetching, set state to "Loading"
      setState(prevState => ({ ...prevState, isLoading: true }))

      // we register an event listener, which will fire off
      // when the data transfer is complete
      // we store the JSON response in our state
      request.addEventListener('load', () => {
        setState(prevState => ({
          ...prevState,
          isLoading: false,
          loadedData: [...request.response],
        }))
      })

      // we register an event listener if our request fails
      request.addEventListener('error', () => {
        setState(prevState => ({
          ...prevState,
          isLoading: false,
          isError: true,
        }))
      })

      // we set the request method, the url for the request
      request.open('GET', 'https://ghibliapi.herokuapp.com/people')
      // and send it off to the aether
      request.send()
    }

    // calling the fetchData function will start the data fetching process
    fetchData()

    // if the component is not mounted, we can cancel the request
    // in the cleanup function
    return () => {
      request.abort()
    }
  }, [])

  return <JSX here />
}
Enter fullscreen mode Exit fullscreen mode

You can see it in action on CodeSandBox.

That's not too bad, and you avoid the pesky React warning.

The code is more difficult to understand because the XMLHttpRequest API is not very intuitive. Other than that, it's only some more lines than a promised-based fetch - but with cancellation!

Conclusion

We've now seen a few approaches to avoiding setting state on a unmounted component.

The best approach is to trouble-shoot your code. Perhaps you can avoid unmounting your component.

But if you need another method, you've now seen some ways to avoid a React warning when fetching data.

Acknowledgments

The idea to use XMLHttpRequest is not mine.

Cheng Lou introduced me to it in the ReasonML Discord Channel and even gave an example in ReasonReact.

Links

Top comments (2)

Collapse
 
yawaramin profile image
Yawar Amin

I recently also found this method to cancel a fetch request: developers.google.com/web/updates/...

Looks like it's fairly widely supported now.

Collapse
 
sophiabrandt profile image
Sophia Brandt

Wow, that's great to know. Thank you for pointing this out.