Тихий убийца React-приложений: как избежать ошибок с useEffect

В каждом третьем React-приложении, где я проводил код-ревью, обнаруживалась одна и та же проблема: некорректное использование хука useEffect. Разработчики добавляют эффекты для подписок, анимаций или выборки данных, но забывают про две ключевые вещи: управление зависимостями и очистку ресурсов. Последствия — утечки памяти, неконтролируемые ререндеры и глючный UI.

Проблема: эффекты-зомби

Рассмотрим типичный пример с подпиской на внешний источник данных:

javascript
function StockTicker({ symbol }) {
  const [price, setPrice] = useState();
  
  useEffect(() => {
    const ws = new WebSocket('wss://api.stocks.com');
    ws.onmessage = (msg) => {
      setPrice(JSON.parse(msg.data).price);
    };
  }, [symbol]);
  
  return <div>{price ?? 'Loading...'}</div>;
}

Здесь сразу три ошибки:

  1. Отсутствие очистки сокета при размонтировании
  2. Неправильный массив зависимостей
  3. Потенциальная утечка памяти при быстрой смене пропсов

Исправленная версия:

javascript
function StockTicker({ symbol }) {
  const [price, setPrice] = useState();
  
  useEffect(() => {
    let isMounted = true;
    const ws = new WebSocket(`wss://api.stocks.com/${symbol}`);
    
    const handleMessage = (msg) => {
      if (!isMounted) return;
      setPrice(JSON.parse(msg.data).price);
    };
    
    ws.onmessage = handleMessage;
    
    return () => {
      isMounted = false;
      ws.close();
    };
  }, [symbol]);
  
  return <div>{price ?? 'Loading...'}</div>;
}

Ключевые изменения:

  • Флаг isMounted предотвращает обновление состояния размонтированного компонента
  • Явное закрытие WebSocket в функции очистки
  • Динамическое построение URL на основе symbol в теле эффекта

Как useEffect работает на самом деле

Механизм эффектов в React часто становится источником недопонимания. Рассмотрим его жизненный цикл:

  1. Монтирование
    → Выполняется тело эффекта
    → React сохраняет возвращённую функцию очистки

  2. Обновление
    → Выполняется функция очистки предыдущего эффекта
    → Выполняется тело нового эффекта
    → Сохраняется новая функция очистки

  3. Размонтирование
    → Выполняется последняя функция очистки

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

Асинхронные эффекты: тонкости обработки

Попытка использовать async/await напрямую в useEffect — классическая ошибка:

javascript
// Так делать нельзя!
useEffect(async () => {
  const data = await fetchData();
  setState(data);
}, []);

React не может корректно обработать асинхронную функцию очистки. Правильный подход:

javascript
useEffect(() => {
  let isActive = true;
  
  const loadData = async () => {
    const data = await fetchData();
    if (isActive) {
      setState(data);
    }
  };
  
  loadData();
  
  return () => {
    isActive = false;
  };
}, []);

Особое внимание здесь на:

  1. Флаг isActive для отмены устаревших запросов
  2. Отдельная асинхронная функция внутри эффекта
  3. Возможность расширения для отмены fetch через AbortController

Оптимизация производительности

Избыточные ререндеры из-за эффектов — частая причина медленной работы приложений. Решение — тщательный подбор зависимостей:

javascript
const [user, setUser] = useState(null);
const [projects, setProjects] = useState([]);

// Плохо: эффект срабатывает при любом изменении user
useEffect(() => {
  if (user?.id) {
    fetchProjects(user.id).then(setProjects);
  }
}, [user]);

// Хорошо: выделяем конкретную зависимость
useEffect(() => {
  if (user?.id) {
    fetchProjects(user.id).then(setProjects);
  }
}, [user?.id]); // Сработает только при изменении ID пользователя

Но тут кроется новый подводный камень: если user обновляется как новый объект с тем же ID, эффект выполнится снова. Для сложных объектов используйте хеширование зависимостей:

javascript
const userHash = JSON.stringify({ id: user.id, role: user.role });

useEffect(() => {
  // Логика эффекта
}, [userHash]);

Когда не стоит использовать useEffect

30% случаев использования эффектов — попытки синхронизировать состояние, что лучше решается через:

  1. Вычисляемые значения

    javascript
    const fullName = useMemo(() => 
      `${firstName} ${lastName}`, 
      [firstName, lastName]
    );
    
  2. Обработчики событий
    Для действий по клику/вводу используйте колбеки, а не эффекты

  3. Сброс состояния при пропс-изменениях
    Вместо эффектов с setState используйте:

    javascript
    function ProfilePage({ userId }) {
      const [comment, setComment] = useState('');
    
      // Это сбрасывает состояние при смене userId
      return <Profile userId={userId} key={userId} />;
    }
    

Инструменты отладки

Для сложных эффектов используйте:

  • useEffectEvent (экспериментальный хук в React 19)
  • Кастомные хуки-обёртки с логированием
  • React DevTools Profiler для отслеживания ненужных выполнений

Пример хука с отладкой:

javascript
function useDebugEffect(name, effect, deps) {
  useEffect(() => {
    console.log(`[EFFECT START] ${name}`);
    const cleanup = effect();
    return () => {
      console.log(`[EFFECT CLEANUP] ${name}`);
      cleanup?.();
    };
  }, deps);
}

Эффекты — мощный, но опасный инструмент. Как говорит принцип React: «Покажите мне ваш код на useEffect, и я скажу, какой у вас стаж разработки». Используйте их осознанно, всегда проверяйте ESLint-правила для зависимостей, и помните — часто лучший эффект это тот, которого нет в коде.

text