React: Предотвращение обновления состояния на немонтируемых компонентах

Вы могли видеть следующее предупреждение, случайно появляющееся в консоли браузера, когда вы отлаживаете свое приложение React:

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.

Вы когда-нибудь задумывались, почему это происходит?

Это происходит по следующему сценарию:

  • Вы выполняете асинхронный вызов (например, сетевой вызов) внутри компонента.
  • Компонент, который выполнил вызов, размонтируется из-за какого-то действия пользователя (например, пользователь переходит в другое место).
  • Асинхронный вызов отвечает, и у вас есть вызов setState в обработчике успеха.

В приведенном выше случае React пытается установить состояние не смонтированного компонента, что не нужно, поскольку компонент больше не находится в области видимости. Следовательно, React предупреждает нас о том, что есть часть кода, которая пытается обновить состояние немонтированного компонента. Как предполагает React, это не внесет никаких ошибок в приложение, однако может потребовать ненужной памяти.

В этой статье мы рассмотрим различные сценарии, в которых может возникнуть эта ошибка, и способы их устранения.

Вызовы выборки

Рассмотрим следующий код:

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
Вход в полноэкранный режим Выйти из полноэкранного режима

Здесь, когда компонент установлен, мы вызываем JSON Placeholder API и отображаем посты в списке.

Теперь включите компонент в компонент App:

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
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь, если вы запустите код и нажмете на ‘Fetch Posts’, а затем сразу же нажмете на ‘Hide Posts’, еще до получения ответа, вы увидите сообщение в журнале (даже если компонент не смонтирован) и предупреждение в консоли:

Вы можете установить дросселирование на Slow 3G, если ответ приходит быстро, и вы не успеваете нажать на ‘Hide Posts’.

Как решить проблему с этим предупреждением?

Существует интерфейс AbortController, который помогает отменять веб-запросы, когда это необходимо пользователю.

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
Вход в полноэкранный режим Выход из полноэкранного режима

Как видно из приведенного выше кода, мы получаем доступ к AbortSignal и передаем его в запрос fetch. Всякий раз, когда компонент размонтируется, мы будем прерывать запрос (в обратном вызове useEffect).

Вызовы Axios

Давайте перепишем компонент FetchPosts для использования axios.

Убедитесь, что вы установили axios с помощью следующей команды (или используйте npm i axios):

yarn add axios
Войдите в полноэкранный режим Выйти из полноэкранного режима

Теперь используйте его в компоненте AxiosPosts:

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
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь, если вы включите AxiosPosts в компонент App и нажмете на ‘Fetch Posts’ и ‘Hide Posts’ до получения ответа, вы увидите предупреждение.

Чтобы отменить предыдущие запросы в React, в axios есть нечто, называемое CancelToken. В своей предыдущей статье я подробно объяснил, как отменить предыдущие запросы в axios. Здесь мы будем использовать ту же логику.

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
Вход в полноэкранный режим Выход из полноэкранного режима

Начиная с версии axios v0.22.0, CancelToken устарел, и axios рекомендует использовать AbortController, как мы использовали в вызовах fetch. Вот как будет выглядеть код, если мы используем 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

setTimeout — это еще один асинхронный вызов, при котором мы можем столкнуться с этим предупреждением.

Рассмотрим следующий компонент:

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
Вход в полноэкранный режим Выход из полноэкранного режима

Здесь у нас есть состояние с начальным значением ‘Timer Running’, которое будет установлено в ‘Times Up!’ через 5 секунд. Если вы переключите таймер до истечения времени, вы получите предупреждение.

Это можно исправить, вызвав clearTimeout для идентификатора таймаута, возвращенного вызовом setTimeout, как показано ниже:

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

Аналогично setTimeout, мы можем исправить предупреждение, вызывая clearInterval всякий раз, когда вызывается функция очистки useEffect:

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
Вход в полноэкранный режим Выход из полноэкранного режима

Слушатели событий

Слушатели событий — еще один пример асинхронных вызовов. Допустим, есть поле, и вы хотите определить, щелкнул ли пользователь внутри или вне поля. Тогда, как я описывал в одной из своих предыдущих статей, мы привяжем к документу слушатель onClick и проверим, сработал ли щелчок внутри поля или нет:

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
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь, если вы нажмете на ‘Toggle Box’, будет показано окно. Если вы щелкните в любом месте, сообщение изменится в зависимости от того, где вы щелкнули. Если сейчас скрыть поле, нажав на ‘Toggle Box’, и щелкнуть в любом месте документа, вы увидите предупреждение в консоли.

Вы можете исправить это, вызвав removeEventListener во время очистки useEffect:

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
Вход в полноэкранный режим Выход из полноэкранного режима

Исходный код

Вы можете просмотреть полный исходный код здесь.

Оцените статью
devanswers.ru
Добавить комментарий