Skip to main content
Source Link
Mulan
  • 136.2k
  • 35
  • 240
  • 276

2023

This is typically happening when running setState after waiting for an asynchronous function. If the component is no longer mounted when the response arrives, attempting to set state when the response arrives will result in the error message you are seeing.

useEffect(
  () => {
    someAsyncFunction().then(data => {
      setData(data)  // ❌ unsafe call of setData
    })
  },
  …
)

We can fix this by setting a local flag in the effect, and using the effect clean-up function to switch the flag when the component unmounts. The Fetching Data guide from the React docs has more details -

useEffect(
  () => {
    let mounted = true // ✅ component is mounted
    someAsyncFunction().then(data => {
      if (mounted) setData(data)  // ✅ setState only if mounted
    })
    return () => {
      mounted = false // ✅ component is unmounted
    }
  },
  …
)

custom hook, no callbacks

But can you imagine writing all of that each time you needed to run an asynchronous function in one of your components? Thankfully we can easily encapsulate this logic in a custom hook.

Other answers here provide a clumsy API that ask the user to specify onSuccess or onError callbacks. This reminds me of the code everyone was writing before we had promises. Let's see if we can do better -

import { DependencyList, useEffect, useState } from "react"

type UseAsyncHookState<T> =
  | { kind: "loading" }
  | { kind: "error", error: Error }
  | { kind: "result", data: T }

function useAsync<T>(func:() => Promise<T>, deps: DependencyList) {
  const [state, setState] = useState<UseAsyncHookState<T>>({ kind: "loading" })
  useEffect(
    () => {
      let mounted = true
      func()
        .then(data => mounted && setState({ kind: "result", data }))
        .catch(error => mounted && setState({ kind: "error", error }))
      return () => {
        mounted = false
      }
    },
    deps,
  )
  return state
}

Our hook helps us detangle complex API logic from component state and lifecycle. With a well-defined type and simple API method -

type User = { … }

async function getUser(id: number): Promise<User> {
  return …
}

We can write our component in declarative way, without the need for callbacks -

function UserProfile(props: { userId: number }) {
  const user = useAsync( // ✅ type automatically inferred
    () => getUser(props.userId),  // async call
    [props.userId],               // dependencies
  )
  switch (user.kind) { // ✅ exhaustive switch..case
    case "loading":
      return <Loading />
    case "result":
      return <UserData user={user.data} />
    case "error":
      return <Error message={user.error.message} />
  }
}

This also takes advantage of TypeScript 5's new switch..case exhaustive completions, ensuring that our component correctly checks for all state possibilities of the hook.

vanilla javascript

For non-TypeScript users, here's the vanilla hook that does the exact same thing -

import { useState, useEffect } from "react"

function useAsync(func, deps) {
  const [state, setState] = useState({ kind: "loading" })
  useEffect(
    () => {
      let mounted = true
      func()
        .then(data => mounted && setState({ kind: "result", data }))
        .catch(error => mounted && setState({ kind: "error", error }))
      return () => {
        mounted = false;
      }
    },
    deps
  )
  return state
}