Skip to content
react

React prevent state updates on unmounted components

Dec 17, 2021Abhishek EH9 Min Read
React prevent state updates on unmounted components

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.

unmounted warning

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:

FetchPosts.js
1import { useEffect, useState } from "react"
2
3const 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 }
18
19 fetchData()
20 }, [])
21 return (
22 <ul>
23 {posts.map(post => {
24 return <li key={post.id}>{post.title}</li>
25 })}
26 </ul>
27 )
28}
29
30export 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:

App.js
1import React, { useState } from "react"
2import FetchPosts from "./FetchPosts"
3
4function App() {
5 const [showPosts, setShowPosts] = useState()
6
7 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}
15
16export 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:

fetch warning

You can set the throttling to Slow 3G if the response comes quickly and you are unable to click on 'Hide Posts' on time.

network throttling

How to solve this warning?

There is an interface called AbortController, which helps in cancelling web requests whenever user needs to.

FetchPosts.js
1import { useEffect, useState } from "react"
2
3const FetchPosts = () => {
4 const [posts, setPosts] = useState([])
5 useEffect(() => {
6 const controller = new AbortController()
7 const signal = controller.signal
8 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 }
23
24 fetchData()
25
26 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}
38
39export 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:

AxiosPosts.js
1import axios from "axios"
2import { useEffect, useState } from "react"
3
4export 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.data
14 setPosts(data)
15 } catch (e) {
16 console.log(e)
17 }
18 }
19
20 fetchData()
21 }, [])
22 return (
23 <ul>
24 {posts.map(post => {
25 return <li key={post.id}>{post.title}</li>
26 })}
27 </ul>
28 )
29}
30
31export 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.

AxiosPosts.js
1import axios from "axios"
2import { useEffect, useState } from "react"
3
4export const AxiosPosts = () => {
5 const [posts, setPosts] = useState([])
6 useEffect(() => {
7 let cancelToken
8
9 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.data
18 setPosts(data)
19 } catch (e) {
20 console.log(e)
21 }
22 }
23
24 fetchData()
25
26 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}
38
39export 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:

AxiosPosts.js
1import axios from "axios"
2import { useEffect, useState } from "react"
3
4export const AxiosPosts = () => {
5 const [posts, setPosts] = useState([])
6 useEffect(() => {
7 const controller = new AbortController()
8 const signal = controller.signal
9
10 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.data
20 setPosts(data)
21 } catch (e) {
22 console.log(e)
23 }
24 }
25
26 fetchData()
27
28 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}
40
41export default AxiosPosts

setTimeout calls

setTimeout is another asynchronous call where we would encounter this warning.

Consider the following component:

Timeout.js
1import React, { useEffect, useState } from "react"
2
3const 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}
12
13const 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}
22
23export 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:

Timeout.js
1import React, { useEffect, useRef, useState } from "react"
2
3const Timer = () => {
4 const [message, setMessage] = useState("Timer Running")
5 // reference used so that it does not change across renders
6 let timeoutID = useRef(null)
7 useEffect(() => {
8 timeoutID.current = setTimeout(() => {
9 setMessage("Times Up!")
10 }, 5000)
11
12 return () => {
13 clearTimeout(timeoutID.current)
14 console.log("timeout cleared")
15 }
16 }, [])
17 return <div>{message}</div>
18}
19
20const 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}
29
30export default Timeout

setInterval calls

Similar to setTimeout, we can fix the warning by calling clearInterval whenever the useEffect cleanup function is called:

Interval.js
1import React, { useEffect, useRef, useState } from "react"
2
3const CountDown = () => {
4 const [remaining, setRemaining] = useState(10)
5 // reference used so that it does not change across renders
6 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 : existingValue
13 )
14 }, 1000)
15 }
16 return () => {
17 clearInterval(intervalID.current)
18 }
19 }, [])
20 return <div>Time Left: {remaining}s</div>
21}
22
23const 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}
32
33export 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:

DocumentClick.js
1import React, { useEffect, useRef, useState } from "react"
2
3const Box = () => {
4 const ref = useRef(null)
5 const [position, setPosition] = useState("")
6
7 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 }, [])
17
18 return (
19 <>
20 <div>{position ? `Clicked ${position}` : "Click somewhere"}</div>
21 <div
22 ref={ref}
23 style={{
24 width: "200px",
25 height: "200px",
26 border: "solid 1px",
27 }}
28 ></div>
29 </>
30 )
31}
32
33const DocumentClick = () => {
34 const [showBox, setShowBox] = useState(false)
35 return (
36 <>
37 <div
38 style={{
39 display: "flex",
40 justifyContent: "center",
41 alignItems: "center",
42 flexDirection: "column",
43 height: "100vh",
44 }}
45 >
46 <button
47 style={{ marginBottom: "1rem" }}
48 onClick={() => setShowBox(!showBox)}
49 >
50 Toggle Box
51 </button>
52 {showBox && <Box />}
53 </div>
54 </>
55 )
56}
57
58export 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:

DocumentClick.js
1import React, { useEffect, useRef, useState } from "react"
2
3const Box = () => {
4 const ref = useRef(null)
5 const [position, setPosition] = useState("")
6
7 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 }, [])
20
21 return (
22 <>
23 <div>{position ? `Clicked ${position}` : "Click somewhere"}</div>
24 <div
25 ref={ref}
26 style={{
27 width: "200px",
28 height: "200px",
29 border: "solid 1px",
30 }}
31 ></div>
32 </>
33 )
34}
35
36const DocumentClick = () => {
37 const [showBox, setShowBox] = useState(false)
38 return (
39 <>
40 <div
41 style={{
42 display: "flex",
43 justifyContent: "center",
44 alignItems: "center",
45 flexDirection: "column",
46 height: "100vh",
47 }}
48 >
49 <button
50 style={{ marginBottom: "1rem" }}
51 onClick={() => setShowBox(!showBox)}
52 >
53 Toggle Box
54 </button>
55 {showBox && <Box />}
56 </div>
57 </>
58 )
59}
60
61export default DocumentClick

Source Code

You can view the complete source code here.

If you have liked article, do follow me on twitter to get more real time updates!

Leave a Comment

© 2022 CodingDeft.Com