Avatar ·

Ошибка React: «Can't perform a React state update on an unmounted component» в TypeScript

Привет! Я пишу React-приложение на TypeScript с использованием Lodash. У меня есть компонент, который загружает данные с сервера при монтировании и отображает их. Всё работает, но в консоли постоянно появляется предупреждение: «Can't perform a React state update on an unmounted component». Это случается, когда пользователь быстро переключается между страницами. Вот упрощённый код моего компонента: ```typescript import React, { useState, useEffect } from 'react'; import { debounce } from 'lodash'; interface UserData { id: number; name: string; } const UserProfile: React.FC<{ userId: number }> = ({ userId }) => { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const fetchUser = async () => { setLoading(true); try { const response = await fetch(`/api/users/${userId}`); const data: UserData = await response.json(); setUser(data); } catch (error) { console.error('Ошибка загрузки:', error); } finally { setLoading(false); } }; fetchUser(); }, [userId]); if (loading) return
Загрузка...
; if (!user) return
Пользователь не найден
; return
{user.name}
; }; ``` Проблема: если пользователь уходит со страницы до завершения fetch, компонент размонтируется, но setUser или setLoading всё ещё вызываются. Как правильно обработать эту ситуацию в TypeScript? Нужно ли использовать AbortController или есть более простой способ? Также интересует, как это связано с Lodash debounce — я планирую добавить поиск с debounce, но боюсь, что ошибка усугубится. <<>>

Почему возникает ошибка и как её исправить

Ошибка «Can't perform a React state update on an unmounted component» возникает, когда асинхронная операция (например, fetch, setTimeout или debounced callback) пытается обновить состояние компонента после того, как он был удалён из DOM. React считает это утечкой памяти и потенциальным багом, поэтому выводит предупреждение.

Основное решение: флаг mounted или AbortController

В TypeScript лучший способ — использовать AbortController. Это современный API для отмены fetch-запросов и других асинхронных операций. Вот исправленный код:

import React, { useState, useEffect, useRef } from 'react';

interface UserData {
  id: number;
  name: string;
}

const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
  const [user, setUser] = useState<UserData | null>(null);
  const [loading, setLoading] = useState<boolean>(true);

  useEffect(() => {
    const abortController = new AbortController();

    const fetchUser = async () => {
      setLoading(true);
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: abortController.signal, // передаём сигнал
        });
        if (!response.ok) throw new Error('Ошибка сети');
        const data: UserData = await response.json();
        // Проверка не обязательна, так как при отмене fetch выбросит исключение
        setUser(data);
      } catch (error) {
        if (error instanceof DOMException && error.name === 'AbortError') {
          console.log('Запрос отменён — компонент размонтирован');
          return; // Не обновляем состояние
        }
        console.error('Ошибка загрузки:', error);
      } finally {
        if (!abortController.signal.aborted) {
          setLoading(false);
        }
      }
    };

    fetchUser();

    // Очистка: отменяем запрос при размонтировании
    return () => abortController.abort();
  }, [userId]);

  if (loading) return <div>Загрузка...</div>;
  if (!user) return <div>Пользователь не найден</div>;

  return <div>{user.name}</div>;
};

Как работает AbortController

  • new AbortController() создаёт контроллер с сигналом signal.
  • Передаём signal в fetch как опцию.
  • В useEffect cleanup вызываем abortController.abort().
  • При отмене fetch выбрасывает исключение AbortError — мы его ловим и игнорируем.
  • Это гарантирует, что setUser и setLoading не вызовутся после размонтирования.

Альтернатива: флаг isMounted (устаревший подход)

Раньше использовали флаг, но это менее надёжно:

useEffect(() => {
  let isMounted = true;

  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (isMounted) setUser(data);
    })
    .catch(err => console.error(err))
    .finally(() => {
      if (isMounted) setLoading(false);
    });

  return () => { isMounted = false; };
}, [userId]);

Недостаток: запрос всё равно выполняется, тратится трафик. AbortController отменяет его на уровне сети.

Работа с Lodash debounce

Если вы используете debounce (например, для поиска), нужно отменять вызов при размонтировании:

import { debounce } from 'lodash';

const SearchComponent: React.FC = () => {
  const [query, setQuery] = useState('');
  const debouncedSearch = useRef(
    debounce((value: string) => {
      // Здесь может быть fetch с AbortController
      console.log('Поиск:', value);
    }, 300)
  ).current;

  useEffect(() => {
    return () => debouncedSearch.cancel(); // Отменяем при размонтировании
  }, [debouncedSearch]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
};

Используйте useRef, чтобы debounce-функция сохранялась между рендерами. Вызов .cancel() в cleanup предотвращает выполнение после размонтирования.

Рекомендация для TypeScript

  • Всегда типизируйте AbortController — он встроен в lib: "dom".
  • Для сложных случаев (несколько запросов) используйте отдельные контроллеры.
  • Рассмотрите библиотеку react-query или swr — они автоматически управляют отменой запросов.

Исправление этой ошибки делает приложение стабильнее и убирает предупреждения из консоли.

<<>>

Дополнительный подход: кастомный хук useSafeState

Если вы не хотите каждый раз писать логику с AbortController, можно создать обёртку для useState, которая проверяет, смонтирован ли компонент:

import { useState, useEffect, useRef, useCallback } from 'react';

function useSafeState<T>(initial: T): [T, React.Dispatch<React.SetStateAction<T>>] {
  const [state, setState] = useState(initial);
  const isMountedRef = useRef(true);

  useEffect(() => {
    return () => { isMountedRef.current = false; };
  }, []);

  const safeSetState = useCallback((value: React.SetStateAction<T>) => {
    if (isMountedRef.current) {
      setState(value);
    }
  }, []);

  return [state, safeSetState];
}

// Использование:
const [user, setUser] = useSafeState<UserData | null>(null);
const [loading, setLoading] = useSafeState(true);

Этот хук автоматически предотвращает обновление состояния после размонтирования. Минус: запрос всё равно выполняется. Для простых случаев (не критические данные) этого достаточно. Для fetch с отменой лучше использовать AbortController.

Совмещение с AbortController

Можно комбинировать: useSafeState + AbortController для полной защиты. Но обычно достаточно одного из подходов. Если у вас много асинхронных операций в разных компонентах — useSafeState сократит дублирование кода.

Также рекомендую настроить ESLint правило react-hooks/exhaustive-deps — оно поможет не забыть зависимости в useEffect.