You might have seen the following warning randomly appearing in your browser console, whenever you are debugging your React app:
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.
Ever wondered why this happens?
This happens in the following scenario:
- You make an asynchronous call (eg: Network call) inside a component.
- The component which made the call gets unmounted due to some user action (eg: user navigating away).
- The asynchronous call responds and you have
setState
call in the success handler.
In the above case, React tries to set the state of an unmounted component, which is not necessary since the component is not in scope anymore. Hence, React warns us that there is a piece of code that tries to update the state of an unmounted component. As React suggests, this will not introduce any bugs in the application, however, it might use up unnecessary memory.
In this article, we will see different scenarios, where this error can occur, and how we can fix them.
Fetch calls
Consider the following code:
import { useEffect, useState } from "react"
const FetchPosts = () => {
const [posts, setPosts] = useState([])
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts"
)
console.log("received response")
const data = await response.json()
setPosts(data)
} catch (e) {
console.log(e)
}
}
fetchData()
}, [])
return (
<ul>
{posts.map(post => {
return <li key={post.id}>{post.title}</li>
})}
</ul>
)
}
export default FetchPosts
Here, when the component is mounted, we are calling the JSON Placeholder API and displaying the posts in a list.
Now include the component in the App
component:
import React, { useState } from "react"
import FetchPosts from "./FetchPosts"
function App() {
const [showPosts, setShowPosts] = useState()
return (
<div>
<button onClick={() => setShowPosts(true)}>Fetch Posts</button>
<button onClick={() => setShowPosts(false)}>Hide Posts</button>
{showPosts && <FetchPosts />}
</div>
)
}
export default App
Now if you run the code and click on 'Fetch Posts' and then click on 'Hide Posts' immediately, even before the response is received, you will see the message being logged (even though the component is unmounted) and a warning in the console:
You can set the throttling to Slow 3G if the response comes quickly and you are unable to click on 'Hide Posts' on time.
How to solve this warning?
There is an interface called AbortController, which helps in cancelling web requests whenever user needs to.
import { useEffect, useState } from "react"
const FetchPosts = () => {
const [posts, setPosts] = useState([])
useEffect(() => {
const controller = new AbortController()
const signal = controller.signal
const fetchData = async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts",
{
signal: signal,
}
)
console.log("received response")
const data = await response.json()
setPosts(data)
} catch (e) {
console.log(e)
}
}
fetchData()
return () => {
controller.abort()
}
}, [])
return (
<ul>
{posts.map(post => {
return <li key={post.id}>{post.title}</li>
})}
</ul>
)
}
export default FetchPosts
As you can see in the above code, we access the AbortSignal and pass it to the fetch request. Whenever the component is unmounted, we will be aborting the request (in the return callback of useEffect
).
Axios calls
Let's rewrite the FetchPosts
component to make use of axios.
Make sure that you have installed axios using the following command (or use npm i axios
):
yarn add axios
Now use it in the AxiosPosts
component:
import axios from "axios"
import { useEffect, useState } from "react"
export const AxiosPosts = () => {
const [posts, setPosts] = useState([])
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get(
"https://jsonplaceholder.typicode.com/posts"
)
console.log("received response")
const data = response.data
setPosts(data)
} catch (e) {
console.log(e)
}
}
fetchData()
}, [])
return (
<ul>
{posts.map(post => {
return <li key={post.id}>{post.title}</li>
})}
</ul>
)
}
export default AxiosPosts
Now, if you include AxiosPosts
in the App component and click on 'Fetch Posts' and 'Hide Posts' before the response is received, you will see the warning.
To cancel previous requests in React, axios has something called CancelToken. In my previous article, I have explained in detail how to cancel previous requests in axios. We will make use of the same logic here.
import axios from "axios"
import { useEffect, useState } from "react"
export const AxiosPosts = () => {
const [posts, setPosts] = useState([])
useEffect(() => {
let cancelToken
const fetchData = async () => {
cancelToken = axios.CancelToken.source()
try {
const response = await axios.get(
"https://jsonplaceholder.typicode.com/posts",
{ cancelToken: cancelToken.token }
)
console.log("received response")
const data = response.data
setPosts(data)
} catch (e) {
console.log(e)
}
}
fetchData()
return () => {
cancelToken.cancel("Operation canceled.")
}
}, [])
return (
<ul>
{posts.map(post => {
return <li key={post.id}>{post.title}</li>
})}
</ul>
)
}
export default AxiosPosts
As of axios v0.22.0
, CancelToken is deprecated and axios recommends to use AbortController
like we used in fetch
calls. This is how the code would look like if we are making use of AbortController
:
import axios from "axios"
import { useEffect, useState } from "react"
export const AxiosPosts = () => {
const [posts, setPosts] = useState([])
useEffect(() => {
const controller = new AbortController()
const signal = controller.signal
const fetchData = async () => {
try {
const response = await axios.get(
"https://jsonplaceholder.typicode.com/posts",
{
signal: signal,
}
)
console.log("received response")
const data = response.data
setPosts(data)
} catch (e) {
console.log(e)
}
}
fetchData()
return () => {
controller.abort()
}
}, [])
return (
<ul>
{posts.map(post => {
return <li key={post.id}>{post.title}</li>
})}
</ul>
)
}
export default AxiosPosts
setTimeout calls
setTimeout is another asynchronous call where we would encounter this warning.
Consider the following component:
import React, { useEffect, useState } from "react"
const Timer = () => {
const [message, setMessage] = useState("Timer Running")
useEffect(() => {
setTimeout(() => {
setMessage("Times Up!")
}, 5000)
}, [])
return <div>{message}</div>
}
const Timeout = () => {
const [showTimer, setShowTimer] = useState(false)
return (
<div>
<button onClick={() => setShowTimer(!showTimer)}>Toggle Timer</button>
<div>{showTimer && <Timer />}</div>
</div>
)
}
export default Timeout
Here we have a state having an initial value of 'Timer Running', which will be set to 'Times Up!' after 5 seconds. If you toggle the timer before the timeout happens, you will get the warning.
We can fix this by calling clearTimeout on the timeout ID returned by the setTimeout
call, as shown below:
import React, { useEffect, useRef, useState } from "react"
const Timer = () => {
const [message, setMessage] = useState("Timer Running")
// reference used so that it does not change across renders
let timeoutID = useRef(null)
useEffect(() => {
timeoutID.current = setTimeout(() => {
setMessage("Times Up!")
}, 5000)
return () => {
clearTimeout(timeoutID.current)
console.log("timeout cleared")
}
}, [])
return <div>{message}</div>
}
const Timeout = () => {
const [showTimer, setShowTimer] = useState(false)
return (
<div>
<button onClick={() => setShowTimer(!showTimer)}>Toggle Timer</button>
<div>{showTimer && <Timer />}</div>
</div>
)
}
export default Timeout
setInterval calls
Similar to setTimeout, we can fix the warning by calling clearInterval whenever the useEffect
cleanup function is called:
import React, { useEffect, useRef, useState } from "react"
const CountDown = () => {
const [remaining, setRemaining] = useState(10)
// reference used so that it does not change across renders
let intervalID = useRef(null)
useEffect(() => {
if (!intervalID.current) {
intervalID.current = setInterval(() => {
console.log("interval")
setRemaining(existingValue =>
existingValue > 0 ? existingValue - 1 : existingValue
)
}, 1000)
}
return () => {
clearInterval(intervalID.current)
}
}, [])
return <div>Time Left: {remaining}s</div>
}
const Interval = () => {
const [showTimer, setShowTimer] = useState(false)
return (
<div>
<button onClick={() => setShowTimer(!showTimer)}>Toggle Timer</button>
<div>{showTimer && <CountDown />}</div>
</div>
)
}
export default Interval
Event listeners
Event listeners is another example of asynchronous calls. Say there is a box and you want to identify if the user has clicked inside or outside the box. Then as I described in one of my previous articles, we will bind an onClick listener to the document and check if the click is triggered within the box or not:
import React, { useEffect, useRef, useState } from "react"
const Box = () => {
const ref = useRef(null)
const [position, setPosition] = useState("")
useEffect(() => {
const checkIfClickedOutside = e => {
if (ref.current && ref.current.contains(e.target)) {
setPosition("inside")
} else {
setPosition("outside")
}
}
document.addEventListener("click", checkIfClickedOutside)
}, [])
return (
<>
<div>{position ? `Clicked ${position}` : "Click somewhere"}</div>
<div
ref={ref}
style={{
width: "200px",
height: "200px",
border: "solid 1px",
}}
></div>
</>
)
}
const DocumentClick = () => {
const [showBox, setShowBox] = useState(false)
return (
<>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
height: "100vh",
}}
>
<button
style={{ marginBottom: "1rem" }}
onClick={() => setShowBox(!showBox)}
>
Toggle Box
</button>
{showBox && <Box />}
</div>
</>
)
}
export default DocumentClick
Now if you click on 'Toggle Box', a box will be shown. If you click anywhere, the message will change based on where you have clicked. If you hide the box now by clicking on the 'Toggle Box' and click anywhere in the document, you will see the warning in the console.
You can fix this by calling removeEventListener during the useEffect
cleanup:
import React, { useEffect, useRef, useState } from "react"
const Box = () => {
const ref = useRef(null)
const [position, setPosition] = useState("")
useEffect(() => {
const checkIfClickedOutside = e => {
if (ref.current && ref.current.contains(e.target)) {
setPosition("inside")
} else {
setPosition("outside")
}
}
document.addEventListener("click", checkIfClickedOutside)
return () => {
document.removeEventListener(checkIfClickedOutside)
}
}, [])
return (
<>
<div>{position ? Clicked </span><span class="p">${</span><span class="nx">position</span><span class="p">}</span><span class="s2">
: "Click somewhere"}</div>
<div
ref={ref}
style={{
width: "200px",
height: "200px",
border: "solid 1px",
}}
></div>
</>
)
}
const DocumentClick = () => {
const [showBox, setShowBox] = useState(false)
return (
<>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
height: "100vh",
}}
>
<button
style={{ marginBottom: "1rem" }}
onClick={() => setShowBox(!showBox)}
>
Toggle Box
</button>
{showBox && <Box />}
</div>
</>
)
}
export default DocumentClick
Source Code
You can view the complete source code here.
Top comments (1)
Warning was removed in react 18 (announcement).