Have you ever gotten this warning while developing React components?
Warning: Can't perform a React state update 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 a
useEffect cleanup function.
This occurs when we try to update the state of a React component after it has been unmounted and removed from the component tree. And that is usually the result of making an async request (usually a data fetch), but before the response is received and the data is stored in component state, the component has already been unmounted.
I typically see these warnings during my Jest unit test runs (with React Testing Library). Of course I mock out my fetch
requests. But if I have a test run that just cares about the initial render, the test finishes and unmounts the test component before the mocked async action finishes.
It can be frustrating because it seems to never happen in our development environments and only tests, which aren't real speed use cases. But I believe the tests may be exposing potential production issues. There are lots of variables that can cause a fetch
request to be slower than expected, so if the UI unmounts before the response returns, our users can find themselves in this situation.
Typically this won't break our apps because React will just ignore our state update function call, but this is still a warning that we want to prevent from happening. So how do we do that? How do we prevent it?
First lets look at a sample "app" that causes this error.
import { useState, useEffect } from 'react'
const Results = () => {
const [items, setItems] = useState([])
useEffect(() => {
fetchItems().then(setItems)
}, [])
return (
<ol>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
)
}
const App = () => {
const [shown, setShown] = useState(false)
return (
<div>
{shown && <Results />}
<button onClick={() => setShown((curShown) => !curShown)}>
{shown ? 'Hide' : 'Show'}
</button>
</div>
)
}
In the example app, <Results />
is rendered based upon the toggle state of the button. When the button is toggled off (using the functional update form), <Results />
is unmounted.
The Results
component itself, asynchronously fetches items, and then calls setItems
(the state updater) when the data comes back. Now, if the button is toggled off after the call to fetchItems
, but before the fetched data has returned, it will be unmounted when setItems
called. And that generates the warning. Try it out and click the button quickly. You'll see the warning in the CodeSandbox console.
Now let's get into the options for fixing.
Option 1 - Variable to track mounted state
Vanilla JavaScript Promises do not have the ability to be cancelled. So the next best alternative to avoid the React warning is to not call the state updater if the component has been unmounted. And in order to do that we need to keep track of the mounted state.
This can be done inline in the same useEffect
call in which we're making our fetch
request.
import { useState, useEffect } from 'react'
const Results = () => {
const [items, setItems] = useState([])
useEffect(() => {
let mounted = true
fetchItems().then((newItems) => {
if (mounted) {
setItems(newItems)
}
})
return () => {
mounted = false
}
}, [])
// render UI
}
The mounted
variable is initialized to true
and then set to false
in the clean-up function returned by useEffect
. That's how the mounted state is maintained. Then when the promise from fetchItems()
resolves, we check to see if mounted
is still true
. If so, we'll call setItems
with the new data. Otherwise, we'll do nothing. It's basically what React would do, but without the warning.
This local variable approach only works within a single useEffect
call without any dependencies. It will not work across multiple useEffect
calls within a component. It also isn't suitable for a useEffect
call that has dependencies because the mounted state will be reset even though the component itself hasn't been unmounted.
Option 2 - Ref to track mounted state
If we need to track the mounted state in multiple useEffect
calls within a component, we can use a ref to maintain the mounted state across component re-renders.
import { useState, useEffect, useRef } from 'react'
const Results = () => {
const [items, setItems] = useState([])
const mountedRef = useRef(false)
// effect just for tracking mounted state
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
useEffect(() => {
fetchItems().then((newItems) => {
if (mountedRef.current) {
setItems(newItems)
}
})
}, [])
useEffect(() => {
// another fetch request for data
// that will use `mountedRef.current`
}, [])
// render UI
}
The first useEffect
call maintains the state with the mountedRef
. It's set to true
when the effect is first run and it's set to false
when the component unmounts. Because the useEffect
call has []
as its dependencies, it'll never run again when the Results
component is re-rendered. But the mountedRef
will continue to keep the mounted state across re-renders.
Now, any other async effects can check the mounted state before calling state updaters because the ref is available everywhere within the component.
Option 3 - Custom Hook to track mounted state
If we need to track the mounted state in a number of different components, this is the perfect case to create a custom Hook. We basically want to move the logic from Option 2 into a custom Hook.
import { useState, useEffect, useRef, useCallback } from 'react'
// returns a function that when called will
// return `true` if the component is mounted
const useMountedState = () => {
const mountedRef = useRef(false)
const isMounted = useCallback(() => mountedRef.current, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
return isMounted
}
const Results = () => {
const [items, setItems] = useState([])
const isMounted = useMountedState()
useEffect(() => {
fetchItems().then((newItems) => {
if (isMounted()) {
setItems(newItems)
}
})
}, [isMounted])
// render UI
}
The useMountedState
custom Hook uses the same ref to maintain the mounted state. However, it returns a function that when called returns the value of the ref. It leverages useCallback
so that we don't recreate a new function every time useMountedState
is called for every re-render of Results
.
Now, useMountedState
can be called in any component, like Results
, that needs to keep track of the mounted state. And the isMounted
function can be called multiple times within a given component as well.
Notice that the isMounted
function becomes a new dependency for the useEffect
call fetching the data. This is why it was important that useMountedState
used useCallback
to memoize the function. Otherwise, the fetch would be called with every re-render of Results
because the function would be newly created with each re-render. For more details, read my post on helper functions in the React useEffect
Hook.
By the way, the awesome react-use
package (that contains every custom Hook imaginable) has the same useMountedState
custom Hook.
Option 4 - Custom Hook to fetch only when mounted
The previous three approaches provided us with a mounted state to do whatever we wished. But we were only using it in conjunction with making an async fetch request. So what if we created a custom Hook that specifically was for restricting async actions to the mounted state?
const useSafeAsync = () => {
const isMounted = useMountedState()
const safeAsync = useCallback((promise) => {
return new Promise((resolve) => {
promise.then((value) => {
if (isMounted()) {
resolve(value)
}
})
})
})
return safeAsync
}
const Results = () => {
const [items, setItems] = useState([])
const safeAsync = useSafeAsync()
useEffect(() => {
safeAsync(fetchItems()).then(setItems)
}, [safeAsync])
// render UI
}
Our useSafeAsync
custom Hook is an abstraction of checking the mounted state after resolving a Promise
. It makes use of the same useMountedState
custom Hook to keep track of the mounted state and returns a function (safeAsync
) that will take the Promise
object returned from the async action. When that promise resolves, it verifies that the component is still mounted before passing along the resolution.
So now in our Results
component, the code is very similar to the initial simple (but broken) code. The only difference is we wrap the return value of fetchItems()
in safeAsync
so that we can know that anything we do afterward will happen when Results
is still mounted.
Notice this time, that safeAsync
is listed as the dependencies of useEffect
. That's why it's key that useSafeAsync
uses useCallback
to memoize the function it returns.
Once again, react-use
has a similar custom Hook that it calls usePromise
. It also handles promise rejection as well. React Query, a library for fetching, caching and updating data, also has a similar API while providing loads of additional functionality. If you're looking for a lower-level wrapper over the Fetch API, but is still "safe", check out useFetch
from the use-http
package.
By the way, this warning can also happen if we update the state in an event listener handler, and we never remove the handler when the component is unmounted. This is actually a bigger problem because it means an event is being maintained for a component that no longer exists. This is a memory leak. The size of the memory leak of course depends on what event is being handled and how often its triggered.
But this is precisely what the clean-up return function of useEffect
is for. Always remember to remove event handlers you create.
const Example = () => {
const [width, setWidth] = useState(0)
useEffect(() => {
const onResize = () => {
setWidth(document.body.clientWidth)
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
}
}, [])
return <span>Width: {width}</span>
}
Keep learning my friends. 🤓