Вы могли видеть следующее предупреждение, случайно появляющееся в консоли браузера, когда вы отлаживаете свое приложение 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
Исходный код
Вы можете просмотреть полный исходный код здесь.