Table of Contents
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:
1import { useEffect, useState } from "react"23const FetchPosts = () => {4 const [posts, setPosts] = useState([])5 useEffect(() => {6 const fetchData = async () => {7 try {8 const response = await fetch(9 "https://jsonplaceholder.typicode.com/posts"10 )11 console.log("received response")12 const data = await response.json()13 setPosts(data)14 } catch (e) {15 console.log(e)16 }17 }1819 fetchData()20 }, [])21 return (22 <ul>23 {posts.map(post => {24 return <li key={post.id}>{post.title}</li>25 })}26 </ul>27 )28}2930export 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:
1import React, { useState } from "react"2import FetchPosts from "./FetchPosts"34function App() {5 const [showPosts, setShowPosts] = useState()67 return (8 <div>9 <button onClick={() => setShowPosts(true)}>Fetch Posts</button>10 <button onClick={() => setShowPosts(false)}>Hide Posts</button>11 {showPosts && <FetchPosts />}12 </div>13 )14}1516export 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.
1import { useEffect, useState } from "react"23const FetchPosts = () => {4 const [posts, setPosts] = useState([])5 useEffect(() => {6 const controller = new AbortController()7 const signal = controller.signal8 const fetchData = async () => {9 try {10 const response = await fetch(11 "https://jsonplaceholder.typicode.com/posts",12 {13 signal: signal,14 }15 )16 console.log("received response")17 const data = await response.json()18 setPosts(data)19 } catch (e) {20 console.log(e)21 }22 }2324 fetchData()2526 return () => {27 controller.abort()28 }29 }, [])30 return (31 <ul>32 {posts.map(post => {33 return <li key={post.id}>{post.title}</li>34 })}35 </ul>36 )37}3839export 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
):
1yarn add axios
Now use it in the AxiosPosts
component:
1import axios from "axios"2import { useEffect, useState } from "react"34export const AxiosPosts = () => {5 const [posts, setPosts] = useState([])6 useEffect(() => {7 const fetchData = async () => {8 try {9 const response = await axios.get(10 "https://jsonplaceholder.typicode.com/posts"11 )12 console.log("received response")13 const data = response.data14 setPosts(data)15 } catch (e) {16 console.log(e)17 }18 }1920 fetchData()21 }, [])22 return (23 <ul>24 {posts.map(post => {25 return <li key={post.id}>{post.title}</li>26 })}27 </ul>28 )29}3031export 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.
1import axios from "axios"2import { useEffect, useState } from "react"34export const AxiosPosts = () => {5 const [posts, setPosts] = useState([])6 useEffect(() => {7 let cancelToken89 const fetchData = async () => {10 cancelToken = axios.CancelToken.source()11 try {12 const response = await axios.get(13 "https://jsonplaceholder.typicode.com/posts",14 { cancelToken: cancelToken.token }15 )16 console.log("received response")17 const data = response.data18 setPosts(data)19 } catch (e) {20 console.log(e)21 }22 }2324 fetchData()2526 return () => {27 cancelToken.cancel("Operation canceled.")28 }29 }, [])30 return (31 <ul>32 {posts.map(post => {33 return <li key={post.id}>{post.title}</li>34 })}35 </ul>36 )37}3839export 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
:
1import axios from "axios"2import { useEffect, useState } from "react"34export const AxiosPosts = () => {5 const [posts, setPosts] = useState([])6 useEffect(() => {7 const controller = new AbortController()8 const signal = controller.signal910 const fetchData = async () => {11 try {12 const response = await axios.get(13 "https://jsonplaceholder.typicode.com/posts",14 {15 signal: signal,16 }17 )18 console.log("received response")19 const data = response.data20 setPosts(data)21 } catch (e) {22 console.log(e)23 }24 }2526 fetchData()2728 return () => {29 controller.abort()30 }31 }, [])32 return (33 <ul>34 {posts.map(post => {35 return <li key={post.id}>{post.title}</li>36 })}37 </ul>38 )39}4041export default AxiosPosts
setTimeout calls
setTimeout is another asynchronous call where we would encounter this warning.
Consider the following component:
1import React, { useEffect, useState } from "react"23const Timer = () => {4 const [message, setMessage] = useState("Timer Running")5 useEffect(() => {6 setTimeout(() => {7 setMessage("Times Up!")8 }, 5000)9 }, [])10 return <div>{message}</div>11}1213const Timeout = () => {14 const [showTimer, setShowTimer] = useState(false)15 return (16 <div>17 <button onClick={() => setShowTimer(!showTimer)}>Toggle Timer</button>18 <div>{showTimer && <Timer />}</div>19 </div>20 )21}2223export 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:
1import React, { useEffect, useRef, useState } from "react"23const Timer = () => {4 const [message, setMessage] = useState("Timer Running")5 // reference used so that it does not change across renders6 let timeoutID = useRef(null)7 useEffect(() => {8 timeoutID.current = setTimeout(() => {9 setMessage("Times Up!")10 }, 5000)1112 return () => {13 clearTimeout(timeoutID.current)14 console.log("timeout cleared")15 }16 }, [])17 return <div>{message}</div>18}1920const Timeout = () => {21 const [showTimer, setShowTimer] = useState(false)22 return (23 <div>24 <button onClick={() => setShowTimer(!showTimer)}>Toggle Timer</button>25 <div>{showTimer && <Timer />}</div>26 </div>27 )28}2930export default Timeout
setInterval calls
Similar to setTimeout,
we can fix the warning by calling clearInterval whenever the useEffect
cleanup function is called:
1import React, { useEffect, useRef, useState } from "react"23const CountDown = () => {4 const [remaining, setRemaining] = useState(10)5 // reference used so that it does not change across renders6 let intervalID = useRef(null)7 useEffect(() => {8 if (!intervalID.current) {9 intervalID.current = setInterval(() => {10 console.log("interval")11 setRemaining(existingValue =>12 existingValue > 0 ? existingValue - 1 : existingValue13 )14 }, 1000)15 }16 return () => {17 clearInterval(intervalID.current)18 }19 }, [])20 return <div>Time Left: {remaining}s</div>21}2223const Interval = () => {24 const [showTimer, setShowTimer] = useState(false)25 return (26 <div>27 <button onClick={() => setShowTimer(!showTimer)}>Toggle Timer</button>28 <div>{showTimer && <CountDown />}</div>29 </div>30 )31}3233export 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:
1import React, { useEffect, useRef, useState } from "react"23const Box = () => {4 const ref = useRef(null)5 const [position, setPosition] = useState("")67 useEffect(() => {8 const checkIfClickedOutside = e => {9 if (ref.current && ref.current.contains(e.target)) {10 setPosition("inside")11 } else {12 setPosition("outside")13 }14 }15 document.addEventListener("click", checkIfClickedOutside)16 }, [])1718 return (19 <>20 <div>{position ? `Clicked ${position}` : "Click somewhere"}</div>21 <div22 ref={ref}23 style={{24 width: "200px",25 height: "200px",26 border: "solid 1px",27 }}28 ></div>29 </>30 )31}3233const DocumentClick = () => {34 const [showBox, setShowBox] = useState(false)35 return (36 <>37 <div38 style={{39 display: "flex",40 justifyContent: "center",41 alignItems: "center",42 flexDirection: "column",43 height: "100vh",44 }}45 >46 <button47 style={{ marginBottom: "1rem" }}48 onClick={() => setShowBox(!showBox)}49 >50 Toggle Box51 </button>52 {showBox && <Box />}53 </div>54 </>55 )56}5758export 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:
1import React, { useEffect, useRef, useState } from "react"23const Box = () => {4 const ref = useRef(null)5 const [position, setPosition] = useState("")67 useEffect(() => {8 const checkIfClickedOutside = e => {9 if (ref.current && ref.current.contains(e.target)) {10 setPosition("inside")11 } else {12 setPosition("outside")13 }14 }15 document.addEventListener("click", checkIfClickedOutside)16 return () => {17 document.removeEventListener(checkIfClickedOutside)18 }19 }, [])2021 return (22 <>23 <div>{position ? `Clicked ${position}` : "Click somewhere"}</div>24 <div25 ref={ref}26 style={{27 width: "200px",28 height: "200px",29 border: "solid 1px",30 }}31 ></div>32 </>33 )34}3536const DocumentClick = () => {37 const [showBox, setShowBox] = useState(false)38 return (39 <>40 <div41 style={{42 display: "flex",43 justifyContent: "center",44 alignItems: "center",45 flexDirection: "column",46 height: "100vh",47 }}48 >49 <button50 style={{ marginBottom: "1rem" }}51 onClick={() => setShowBox(!showBox)}52 >53 Toggle Box54 </button>55 {showBox && <Box />}56 </div>57 </>58 )59}6061export default DocumentClick
Source Code
You can view the complete source code here.
Do follow me on twitter where I post developer insights more often!