Проверка функции useEffect: десять распространённых ошибок, их симптомы и реальные решения
Определите десять наиболее распространённых ошибок в использовании useEffect в React — от отсутствия функций очистки и устаревших замыканий до ситуаций соревнования и синхронизации свойств — и примените соответствующие решения.
Источники: 10 React useEffect Mistakes That Are Secretly Breaking Your App in 2026
React 19, React Compiler и Server Components перенесли множество задач из функции useEffect, однако именно в этом хуке по-прежнему находятся многие самые сложные ошибки в React: утечки ресурсов, устаревшие данные, мерцание экрана и циклы отрисовки. Большинство из них возникает из-за десяти часто встречающихся ошибок. Ниже для каждой из них приведена неисправная версия, симптомы и способ исправления, а также чек-лист для проверки собственных функций useEffect.
Принцип каждого решения заключается в следующем: функция useEffect предназначена для синхронизации компонента с чем-то внешним по отношению к React, таким как таймер, сокет или API браузера.
1. Забывание функции очистки
Таймеры, подписки и обработчики событий, созданные в рамках функции useEffect, продолжают работать после повторной отрисовки или снятия компонента с экрана, если их не отключить. В этом случае интервал так и не аннулируется:
// ❌ Broken: No cleanup
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
// Missing: return () => clearInterval(timer);
}, []);
Симптомами являются утечки интервалов и дублирование слушателей. Во время разработки режим Strict Mode намеренно подключает, отключает и снова подключает компоненты, чтобы показать эту проблему: без очистки появляются два интервала, и счётчик увеличивается дважды в секунду. В продакшене утечка происходит каждый раз, когда компонент снова подключается. Верните функцию, которая отменит выполнение настроек:
// ✅ Fixed: Always clean up
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
2. Использование пустого массива зависимостей для обозначения «запустить один раз»
Пустой массив никак не означает «при подключении» в каком-либо особом смысле. Он сообщает React, что эффект не зависит ни от чего, поэтому он никогда не будет выполняться заново. Если эффект читает пропсы или состояние, эти значения замораживаются на значениях первой отрисовки:
// ❌ Broken: Stale closure
function ChatRoom({ roomId }) {
useEffect(() => {
connectToRoom(roomId);
return () => disconnectFromRoom(roomId);
}, []); // roomId changes, but effect never re-runs
}
Когда меняется roomId, компонент остается в исходной комнате. Укажите все реактивные значения, которые использует эффект; правило проверки react-hooks/exhaustive-deps помогает обнаружить это.
// ✅ Fixed: Include real dependencies
useEffect(() => {
connectToRoom(roomId);
return () => disconnectFromRoom(roomId);
}, [roomId]);
Теперь React разрывает связь с предыдущей комнатой и устанавливает связь с новой каждый раз, когда меняется roomId.
3. Зависимости от объектов, которые создаются заново при каждом рендере
Зависимости сравниваются по ссылке. Объект или массив, созданные во время рендера, имеют новую ссылку каждый раз, поэтому эффект, зависящий от них, перезапускается после каждого рендера:
// ❌ Broken: New object every render
function Parent() {
const config = { timeout: 5000 }; // New reference every time
useEffect(() => {
fetchData(config);
}, [config]); // Runs every single render
}
Стабилизируйте ссылку. Если значение никогда не меняется, определите его один раз вне компонента. Если оно формируется из пропсов или состояния, оберните его в useMemo с соответствующими зависимостями. Приведённый ниже фрагмент показывает оба варианта рядом; на практике следует выбрать один из них:
// ✅ Fixed: Stable reference
const CONFIG = { timeout: 5000 }; // Defined outside component
function Parent() {
const config = useMemo(() => ({ timeout: 5000 }), []);
useEffect(() => {
fetchData(config);
}, [config]);
}
Часто ещё проще: использовать примитивы (timeout вместо config) и формировать объект внутри эффекта. React Compiler мемоизирует многие значения в компилируемом коде, но не объекты, поступающие из некомпилируемого кода или библиотек, поэтому не стоит полагаться на него для корректности эффектов.
4. Позволение асинхронным запросам конкурировать друг с другом
Когда зависимость меняется быстро, например при использовании поля поиска или быстрой смене вкладок, одновременно могут отправляться несколько запросов, и они не обязательно будут выполняться в порядке:
// ❌ Broken: Race condition
useEffect(() => {
fetch(`/api/search?q=${query}`).then(res => res.json()).then(setResults);
}, [query]);
Если пользователь вводит «react», а затем «react hooks», ответ на запрос к «react» может поступить в последнюю очередь и перезаписать правильные результаты. Решение заключается в отмене устаревшего запроса в функции очистки. Объект AbortController прерывает операцию загрузки, а возникающая ошибка AbortError игнорируется, чтобы она не рассматривалась как настоящая проблема:
// ✅ Fixed: Abort in-flight requests
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort();
}, [query]);
Для интерфейсов поиска существуют дополнительные нюансы, о которых рассказано в статье о способах устранения ситуаций конкуренции, которые невозможно решить с помощью техники дебаунсинга.
5. Хранение производных значений в состоянии
Если значение можно вычислить на основе существующих параметров или состояния, ему не нужно свое собственное состояние или функция эффекта:
// ❌ Broken: Redundant state + effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
Каждая замена отображается дважды: сначала с устаревшим значением fullName, в результате чего появляются два источника правды. Вычисляйте значение во время отображения, используя useMemo только для действительно ресурсоемких операций:
// ✅ Fixed: Compute during render
const fullName = `${firstName} ${lastName}`;// Or for expensive computations
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
Обратите внимание, что в примере показаны два варианта; объявление fullName дважды в одном области видимости является синтаксической ошибкой, поэтому следует оставить только одно объявление. Более подробно этот подход рассматривается в более безопасной альтернативе синхронизации состояния с useEffect.
6. Создание цикла обновлений
Эффект, который устанавливает состояние, также вечно зависит от расписания собственных действий:
// ❌ Broken: Infinite loop
useEffect(() => {
setCount(count + 1);
}, [count]);
Обновление запускает рендеринг, рендеринг изменяет значение count, изменившееся значение count снова запускает эффект, и так далее. React в конечном итоге останавливает этот цикл с ошибкой «Превышена максимальная глубина обновления». Функциональное обновление устраняет необходимость чтения значения count внутри эффекта, поэтому оно больше не должно быть его зависимостью:
// ✅ Fixed: Functional update + correct dependencies
useEffect(() => {
const timer = setTimeout(() => {
setCount(c => c + 1); // Functional update avoids dependency
}, 1000);
return () => clearTimeout(timer);
}, []); // No count dependency needed
Обратите внимание, что это меняет поведение: счётчик теперь увеличивается один раз, спустя секунду после монтирования. Для постоянно обновляемого счётчика используйте setInterval с функцией очистки, как в примере ошибки 1. Любое вызов setState внутри эффекта должно сопровождаться условием, гарантирующим его завершение.
7. Полное опущение массива зависимостей
Без второго аргумента эффект выполняется после каждого рендеринга. Иногда это именно то, что нужно, но чаще всего это происходит случайно и является ресурсоемким действием:
// ❌ Broken: Runs on every render
useEffect(() => {
console.log('Effect running');
performHeavyComputation(data);
});
Компилятор React сокращает ненужные перерисовки, но не делает более эффективной обработку операций внутри эффектов, поэтому эффекты, выполняющие тяжелую работу без защиты, остаются узким местом. Укажите реальную зависимость:
// ✅ Fixed: Explicit dependencies
useEffect(() => {
performHeavyComputation(data);
}, [data]);
8. Обработка событий пользователя в эффектах
Эффекты запускаются из-за того, что что-то отрисовано, а не потому, что пользователь совершил действие. Аналитика, связанная с флагом состояния, запускается каждый раз, когда этот флаг меняется, по любой причине:
// ❌ Broken: Event logic in effect
useEffect(() => {
if (isModalOpen) {
trackEvent('modal_opened');
}
}, [isModalOpen]);
Это событие логируется при инициализации, если модальное окно уже открыто, когда родительский элемент изменяет свойства или при восстановлении состояния, а не только при нажатии пользователя. Логику взаимодействия следует разместить в обработчике, который выполняет это действие:
// ✅ Fixed: Event handler
function handleOpenModal() {
setIsModalOpen(true);
trackEvent('modal_opened');
}
Если вы можете назвать действие пользователя, которое должно запустить код, оно должно находиться в обработчике.
9. Получение данных с помощью простого эффекта
Поскольку во многих приложениях компоненты сервера и загрузчики фреймворка обрабатывают начальные данные, необходимость загрузки информации с клиента с помощью эффектов становится реже. Когда загрузка всё же происходит на клиенте, примитивный эффект оказывается нестабильным:
// ❌ Broken: Raw fetch in effect
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
У него нет состояния загрузки, отсутствует обработка ошибок, нет механизма устранения дублирующихся запросов, нет кэширования, и, как показывает ошибка 4, отсутствует защита от конкурентных ситуаций. Библиотеки для загрузки данных управляют всем этим:
// ✅ Fixed: Use a data-fetching library
const { data: user, isLoading, error } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
staleTime: 5 * 60 * 1000,
});
TanStack Query и SWR являются наиболее распространёнными выборами. В их отсутствие следует написать собственный хук с обработкой состояния загрузки, ошибок и возможности прерывания запроса вместо использования специальных эффектов загрузки.
10. Копирование пропсов в состояние и синхронизация их
Компонент, который получает начальное значение, копирует его в состояние, а затем использует эффект для отражения изменений пропсов, фактически работает против самого себя:
// ❌ Broken: Prop-to-state sync
function EmailInput({ initialEmail }) {
const [email, setEmail] = useState(initialEmail);
useEffect(() => {
setEmail(initialEmail); // Sync prop to state
}, [initialEmail]);
return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
Каждый раз, когда родительский компонент передаёт новое значение initialEmail, введённые пользователем данные бесшумно заменяются, после дополнительной отрисовки с старым значением. Необходимо выбрать один подход: сделать компонент полностью управляемым или оставить его неконтролируемым и намеренно сбросить его состояние путём изменения его key, что позволяет React создать новый экземпляр с обновлённым состоянием:
// ✅ Fixed: Controlled or fully uncontrolled
// Option 1: Fully controlled — no internal state
function EmailInput({ email, onChange }) {
return <input value={email} onChange={onChange} />;
}
// Option 2: Fully uncontrolled with key
function EmailInput({ initialEmail }) {
const [email, setEmail] = useState(initialEmail);
return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
// Usage: Reset by changing key
<EmailInput key={userId} initialEmail={user.email} />
Как React 19 меняет ситуацию
Новые версии React делают эти ошибки более заметными и менее необходимыми:
- Режим строгой отладки, предусматривающий двойную установку эффектов только во время разработки, позволяет рано обнаружить отсутствующие или некорректные операции очистки.
- Компилятор React автоматически мемоизирует компоненты и многие значения, что снижает количество случайных повторных отрисовок, но не исправляет логику эффектов.
use позволяет читать обещания и контекст во время отрисовки, устраняя некоторые причины для использования эффектов с асинхронными значениями.Чек-лист перед слиянием для эффектов
Перед выпуском эффекта проверьте:
- У каждой подписки, таймера и обработчика существует соответствующая функция очистки.
- В массиве зависимостей перечислены все реактивные значения, которые читает эффект.
- Зависимости в виде объектов и массивов являются стабильными или заменены на примитивные зависимости.
- Асинхронная работа отменяется или игнорируется при изменении зависимостей.
- Ничто из того, что вычисляется в эффекте, не может быть получено просто во время отрисовки.
- Никакое обновление состояния не может повторно запустить тот же эффект без условия остановки.
- Логика, запускаемая пользователем, находится в обработчиках событий.
- Загрузка данных осуществляется с помощью серверного компонента, загрузчика фреймворка или библиотеки для получения данных, когда это возможно.
- У каждого элемента состояния есть ровно один источник истины.
Заключение
Почти все ошибки в использовании useEffect возникают из-за применения этого хука не для внешней синхронизации. Производные значения должны находиться в функции отрисовки, действия пользователя — в обработчиках, серверные данные — в слое данных, а состояние, которое можно сбросить, — за key. Настоящие подписки, которые остаются, требуют точных зависимостей и процедур очистки. Часто лучшим результатом аудита является удаление ненужного эффекта.