DEV Community 👩‍💻👨‍💻

Cover image for React: Prevent state updates on unmounted components
collegewap
collegewap

Posted on • Originally published at codingdeft.com

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:

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ${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
Enter fullscreen mode Exit fullscreen mode

Source Code

You can view the complete source code here.

Top comments (0)

🌚 Turn on dark mode

🔠 Change your default font

📚 Adjust your experience level to see more relevant content

 

Head to your account's Settings to do all this and more.