Перейти к содержанию
  • Лента
  • Категории
  • Последние
  • Метки
  • Популярные
  • Пользователи
  • Группы
Свернуть
exlends
Категории
  1. Главная
  2. Категории
  3. Фронтенд
  4. useEffect как бомба: частые ошибки и способы их избежать

useEffect как бомба: частые ошибки и способы их избежать

Запланировано Прикреплена Закрыта Перенесена Фронтенд
useeffectreactутечки памяти
1 Сообщения 1 Постеры 2 Просмотры
  • Сначала старые
  • Сначала новые
  • По количеству голосов
Ответить
  • Ответить, создав новую тему
Авторизуйтесь, чтобы ответить
Эта тема была удалена. Только пользователи с правом управления темами могут её видеть.
  • hannadevH Не в сети
    hannadevH Не в сети
    hannadev
    написал отредактировано kirilljsx
    #1

    Если ты когда-нибудь видел в консоли сообщение “Can’t perform a React state update on an unmounted component” - это не просто предупреждение, это знак того, что твой useEffect работает не так, как ты думаешь. Большинство разработчиков относятся к этому хуку спокойно, но на деле он один из самых коварных источников утечек памяти, лишних рендеров и непредсказуемого поведения приложения.

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

    Когда useEffect срабатывает? Не тогда, когда ты думаешь

    Первое, что нужно понять - useEffect выполняется после рендера, а не перед ним. Это значит, что если ты загружаешь данные в useEffect без массива зависимостей, браузер сначала отрисует компонент, потом запустит твой эффект, и только потом обновит DOM. Пользователь видит старые данные, потом - новые. Мигание, скачки контента, ощущение, что приложение тормозит.

    Добавь сюда то, что React Fiber проходит дерево компонентов в два этапа - сначала делает reconciliation (сравнивает что изменилось), потом выполняет эффекты. И дочерние компоненты запустят свои useEffect раньше родительских. Если ты это не учитывал - добро пожаловать в дебаг-ад.

    Вот что происходит на практике:

    • Компонент отрендерился с пустыми данными
    • Пользователь видит пустой список или спиннер
    • Через миллисекунды данные загрузились, компонент перерендерился
    • Если это происходит часто - у тебя скачет UI и растет нагрузка на браузер

    Решение элементарное, но его часто забывают: передавай правильный массив зависимостей. Пустой массив [] означает “запусти этот эффект только один раз при монтировании”. Если переменные меняются - добавь их в зависимости. Просто и работает.

    Лишние срабатывания: когда функции становятся врагом

    Теперь о грабле, на которую падают мидлы. Ты передаешь функцию (обработчик события, колбэк, что угодно) в массив зависимостей useEffect. Звучит логично - зависимость же, верно? Неправильно. На каждом рендере функция пересоздается, поэтому React видит ее как новую зависимость, даже если логика внутри не изменилась. Результат - useEffect срабатывает после каждого рендера, хотя он этого не должен.

    Представь: у тебя есть компонент, который следит за изменениями userId и загружает данные юзера. Ты передаешь в зависимостях обработчик const handleError = () => { ... }. На каждый рендер эта функция создается заново, useEffect видит изменение, запускается снова, опять загружает данные, опять ошибка обработчика… замкнутый круг.

    Как это выглядит в коде:

    // ПЛОХО - функция пересоздается на каждом рендере
    const MyComponent = ({ userId }) => {
      const [user, setUser] = React.useState(null);
      
      const handleError = () => {
        console.error('Ошибка загрузки');
      };
      
      React.useEffect(() => {
        fetchUser(userId).catch(handleError);
      }, [userId, handleError]); // handleError как зависимость = беда
      
      return <div>{user?.name}</div>;
    };
    

    Что здесь творится:

    • После каждого рендера handleError пересоздается
    • Массив зависимостей видит новую функцию
    • useEffect думает, что что-то изменилось
    • Эффект запускается каждый раз, даже если userId не менялся
    • API кричит от количества запросов

    Фикс - использовать useCallback для мемоизации функции:

    // ХОРОШО - функция мемоизирована
    const MyComponent = ({ userId }) => {
      const [user, setUser] = React.useState(null);
      
      const handleError = React.useCallback(() => {
        console.error('Ошибка загрузки');
      }, []); // пустой массив - функция создается один раз
      
      React.useEffect(() => {
        fetchUser(userId).catch(handleError);
      }, [userId, handleError]); // теперь handleError не меняется понапрасну
      
      return <div>{user?.name}</div>;
    };
    

    Теперь handleError создается один раз и не меняется. useEffect срабатывает только когда userId реально изменился. Меньше рендеров, меньше API запросов, браузер дышит спокойнее.

    Когда это особенно больно:

    • Подписки на события: если передаешь обработчик как зависимость, будешь подписываться на одно и то же событие по сто раз в секунду
    • Таймеры и интервалы: забудешь мемоизировать колбэк - таймер будет сбрасываться каждый рендер
    • Асинхронные операции: каждый рендер = новый запрос в API

    Утечка памяти: компонент размонтировался, но эффект еще работает

    Эта ошибка коварна своей незаметностью. Юзер переходит со страницы на другую, компонент размонтируется, но асинхронная операция (загрузка данных, например) еще работает. Когда она завершится, эффект попытается обновить state уже несуществующего компонента.

    Прямо как в реальной жизни - отправил посылку, потом переехал и отключил телефон. Когда курьер пытается тебя найти, он получит ошибку. React выдает: “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”.

    Проблема возникает когда:

    • Компонент запрашивает данные с сервера
    • Юзер не дожидается ответа и уходит со страницы
    • Компонент размонтируется
    • Ответ приходит, эффект пытается вызвать setState
    • React видит, что компонента больше нет, и выдает warning

    Вот типичный сценарий:

    // ПЛОХО - утечка памяти гарантирована
    const UsersList = () => {
      const [users, setUsers] = React.useState([]);
      
      React.useEffect(() => {
        fetch('/api/users')
          .then(r => r.json())
          .then(data => setUsers(data)); // если компонент размонтируется, будет ошибка
      }, []);
      
      return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
    };
    

    Решение - функция очистки (cleanup function). Она срабатывает перед размонтированием компонента или перед следующим запуском useEffect:

    // ХОРОШО - с функцией очистки
    const UsersList = () => {
      const [users, setUsers] = React.useState([]);
      
      React.useEffect(() => {
        let isMounted = true; // флаг для отслеживания монтирования
        
        fetch('/api/users')
          .then(r => r.json())
          .then(data => {
            if (isMounted) { // обновляем state только если компонент еще на странице
              setUsers(data);
            }
          });
        
        return () => {
          isMounted = false; // очистка при размонтировании
        };
      }, []);
      
      return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
    };
    

    Или еще более цивилизованный способ - с AbortController:

    // ЕЩЕ ЛУЧШЕ - отмена запроса
    const UsersList = () => {
      const [users, setUsers] = React.useState([]);
      
      React.useEffect(() => {
        const controller = new AbortController();
        
        fetch('/api/users', { signal: controller.signal })
          .then(r => r.json())
          .then(data => setUsers(data))
          .catch(err => {
            if (err.name !== 'AbortError') {
              console.error('Ошибка загрузки:', err);
            }
          });
        
        return () => controller.abort(); // отменяем запрос при размонтировании
      }, []);
      
      return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
    };
    

    Абортер - это красиво, но требует поддержки браузера. Для старого IE можешь использовать флаг, как в примере выше. Важно понимать суть: любая асинхронная операция в useEffect должна иметь механизм отмены при размонтировании компонента.

    Основные причины утечек памяти в useEffect:

    • Подписки на события: addEventListener без удаления слушателя
    • Таймеры: setTimeout/setInterval без clearTimeout/clearInterval
    • WebSocket и EventSource: подключение без закрытия при размонтировании
    • Асинхронные запросы: завершение операции после размонтирования компонента

    Что еще может пойти не так

    Кроме основных граблей есть еще парочка подводных камней. Забытый массив зависимостей - когда ты вообще не передаешь второй параметр в useEffect. Тогда хук запускается после каждого рендера. Если внутри эффекта ты вызываешь setState, получится бесконечный цикл: рендер - эффект - setState - рендер - эффект - setState…

    // ОЧЕНЬ ПЛОХО - бесконечный цикл
    const Counter = () => {
      const [count, setCount] = React.useState(0);
      
      React.useEffect(() => {
        setCount(count + 1); // эффект запускается после каждого рендера
      }); // нет массива зависимостей!
      
      return <div>{count}</div>; // count увеличивается бесконечно
    };
    

    Вторая проблема - неправильный порядок выполнения. Эффекты в дочерних компонентах запускаются раньше, чем в родительских. Если дочерний компонент полагается на состояние, которое устанавливает родитель в своем useEffect, получится race condition.

    Третья - отладка через console.log. Если ты добавляешь логирование прямо в useEffect без массива зависимостей, логирование будет происходить каждый рендер, что может замаскировать реальную проблему.

    Практический чеклист для проверки useEffect

    Прежде чем деплоить код с useEffect, пройди по этому списку:

    • Всегда ли передаешь массив зависимостей? Если нет - обязательно подумай, почему. Часто это ошибка.
    • Все ли переменные, которые используются в эффекте, в зависимостях? Если функция использует userId, она должна быть в массиве.
    • Есть ли функции в зависимостях? Если да - обернул ли их в useCallback?
    • Есть ли подписки, таймеры, запросы? Если да - есть ли функция очистки, которая их отменяет?
    • Может ли компонент размонтироваться во время выполнения асинхронной операции? Если да - проверь, что setState не вызывается после размонтирования.

    Вот как это может выглядеть в реальном компоненте:

    const DataFetcher = ({ id, onError }) => {
      const [data, setData] = React.useState(null);
      const [loading, setLoading] = React.useState(false);
      
      // Мемоизируем обработчик ошибок
      const handleError = React.useCallback((error) => {
        console.error('Ошибка:', error);
        onError?.(error);
      }, [onError]);
      
      React.useEffect(() => {
        let isMounted = true;
        setLoading(true);
        
        // Используем AbortController для отмены запроса
        const controller = new AbortController();
        
        fetch(`/api/data/${id}`, { signal: controller.signal })
          .then(r => r.json())
          .then(result => {
            if (isMounted) { // проверяем, что компонент еще на странице
              setData(result);
              setLoading(false);
            }
          })
          .catch(err => {
            if (isMounted && err.name !== 'AbortError') {
              handleError(err);
              setLoading(false);
            }
          });
        
        // Функция очистки
        return () => {
          isMounted = false;
          controller.abort();
        };
      }, [id, handleError]); // зависимости: id и мемоизированный обработчик
      
      if (loading) return <div>Загрузка...</div>;
      return <div>{data?.content}</div>;
    };
    

    Этот компонент:

    • Загружает данные только при изменении id
    • Отменяет запрос при размонтировании
    • Не вызывает setState если компонент уже размонтирован
    • Использует мемоизированный обработчик ошибок
    • Правильно передает все зависимости

    О чем стоит подумать дальше

    Если ты начинаешь замечать, что у тебя в компоненте слишком много useEffect-ов, это признак того, что логику нужно переделать. Иногда имеет смысл вынести сложную логику в custom hook или переписать архитектуру компонента. Правило простое: если useEffect становится сложнее, чем сам компонент - что-то не так.

    Также стоит учитывать, что React Strict Mode в разработке специально двойно запускает эффекты, чтобы ловить ошибки. Если твой код падает в Strict Mode - это не баг React-а, это баг твоего кода. Используй это в свою пользу.

    И помни: meньше useEffect-ов - лучше спишь. Если логику можно решить без побочных эффектов - делай это. Не каждое изменение требует useEffect.

    1 ответ Последний ответ
    0

    Здравствуйте! Похоже, вас заинтересовала эта беседа, но у вас ещё нет аккаунта.

    Надоело каждый раз пролистывать одни и те же посты? Зарегистрировав аккаунт, вы всегда будете возвращаться на ту же страницу, где были раньше, и сможете выбирать, получать ли уведомления о новых ответах (по электронной почте или в виде push-уведомлений). Вы также сможете сохранять закладки и ставить лайки постам, чтобы выразить свою благодарность другим участникам сообщества.

    С вашими комментариями этот пост мог бы стать ещё лучше 💗

    Зарегистрироваться Войти

    Категории

    • Главная
    • Новости
    • Фронтенд
    • Бекенд
    • Языки программирования

    Контакты

    • Сотрудничество
    • info@exlends.com

    © 2024 - 2026 ExLends, Inc. Все права защищены.

    Политика конфиденциальности
    • Войти

    • Нет учётной записи? Зарегистрироваться

    • Войдите или зарегистрируйтесь для поиска.
    • Первое сообщение
      Последнее сообщение
    0
    • Лента
    • Категории
    • Последние
    • Метки
    • Популярные
    • Пользователи
    • Группы