useEffect синхронизирует компонент с чем-то за пределами React: подпиской, таймером, браузерным API, сетевым соединением. Он выполняется после того, как React закоммитил рендер на экран: сначала после монтирования, затем после каждого рендера, в котором изменилась одна из его зависимостей.
Рендер должен оставаться чистым, поэтому код, который трогает внешний мир, идёт либо в обработчик события (его вызвало действие пользователя), либо в эффект (его вызвало то, что компонент находится на экране).
Обычно эффект выполняется после того, как браузер отрисовал кадр, поэтому он не задерживает отрисовку; для замеров layout до отрисовки существует useLayoutEffect.
На сервере эффекты не выполняются: при SSR происходит только рендер.
Думайте «держать это в синхронизации с этими значениями», а не «выполнить при монтировании»: эффект не является методом жизненного цикла.
Загрузка данных в голом эффекте работает, но TanStack Query, загрузчики (loaders) роутера или Server Components сами решают вопросы кэширования и гонок.
ReactMiddle
Эффекты и рефы
useEffect и зависимости, cleanup, гонки запросов, когда эффект не нужен, useRef, useLayoutEffect, двойной запуск в StrictMode
Закрыто вопросов
0 / 19
- 0Повторить
- 9Часто спрашивают
Вопросы
Без массива эффект выполняется после каждого рендера; с [] он выполняется один раз после монтирования, плюс очистка при размонтировании; с [a, b] он выполняется после монтирования и после каждого рендера, в котором изменились a или b. React сравнивает каждую зависимость с предыдущим значением через Object.is.
Каждое реактивное значение, которое читает эффект (пропсы, стейт, всё, что из них вычислено в теле компонента), должно быть в списке; это проверяет правило линтера react-hooks/exhaustive-deps.
Зависимости описывают, что эффект читает, а не когда вы хотите его запускать: чтобы он срабатывал реже, меняйте код так, чтобы он читал меньше значений.
Значения снаружи компонента (константы модуля, импорты) и сеттеры из useState стабильны, и их можно не указывать.
Объекты, массивы и функции, созданные во время рендера, каждый раз новые, поэтому в зависимостях они перезапускают эффект после каждого рендера.
[] утверждает, что эффект ничего не берёт из рендера; если это не так, эффект работает с устаревшими значениями.
Функция очистки возвращается из эффекта и отменяет то, что эффект настроил: отписывается, очищает таймер, отменяет запрос. React вызывает её перед повторным запуском эффекта с новыми зависимостями и один раз при размонтировании компонента.
Порядок при изменении зависимости: рендер с новыми значениями, коммит, очистка со старыми значениями, затем эффект с новыми.
Очистка замыкает значения своего рендера, поэтому отключается от старого roomId, а не от нового.
Без очистки появляются утечки и дубли: тикают два интервала, слушатели срабатывают после размонтирования, сокеты остаются открытыми.
Хорошая проверка: монтирование, размонтирование и повторное монтирование должны давать тот же результат, что и одно монтирование; StrictMode в режиме разработки делает ровно это.
Очистка нужна не каждому эффекту: у установки document.title или логирования отменять нечего.
useRef(initial) возвращает изменяемый объект { current }, который живёт столько же, сколько компонент, и сохраняет ту же идентичность между рендерами. Изменение ref.current не вызывает ререндер, поэтому в ref хранят значения, которые не выводятся на экран, а в стейте хранят те, что выводятся.
Типичные случаи: DOM-узлы, id таймеров и интервалов, AbortController, экземпляр виджета, написанного не на React.
Стейт является снимком на каждый рендер, а ref представляет собой одну общую коробку, поэтому колбэк из старого рендера всё равно читает последнее значение ref.current.
Не читайте и не записывайте ref.current во время рендера, кроме ленивой инициализации: результат зависел бы от того, когда React рендерит, а это ломает конкурентный рендеринг и допущения React Compiler.
В типах React 19 useRef требует аргумент: useRef<number | null>(null) возвращает RefObject с изменяемым current.
Создайте ref через useRef<HTMLInputElement>(null) и передайте его в проп ref элемента; после коммита ref.current содержит DOM-узел, а при размонтировании снова становится null. В React 19 функциональный компонент получает ref как обычный проп, поэтому forwardRef больше не нужен.
DOM-рефы используют для того, на что у React нет пропа: фокус, прокрутка, замеры, воспроизведение медиа, интеграция с библиотекой не на React.
Во время первого рендера ref.current равен null; читайте его только в эффектах и обработчиках событий.
Ваш компонент берёт ref из пропсов и ставит его на внутренний элемент; React.ComponentProps<'input'> уже включает этот проп.
forwardRef по-прежнему работает и встречается в старом коде, но его планируют объявить устаревшим.
Не используйте рефы, чтобы менять то, что рендерит React (текст, классы): это задача стейта и пропсов.
В режиме разработки <StrictMode> монтирует каждый компонент, размонтирует и монтирует снова, поэтому каждый эффект проходит цикл: запуск, очистка, запуск. Это намеренный стресс-тест: если второй запуск что-то ломает, у эффекта нет правильной очистки.
Это происходит только в разработке; в продакшене эффект выполняется один раз на монтирование.
Правильное исправление: очистка, которая отменяет настройку (отключиться, очистить таймер, отменить запрос), а не флаг в useRef, пропускающий второй запуск.
Двойной запрос во вкладке Network не страшен, если первый ответ игнорируется или отменяется; с библиотекой для данных второй вызов бесплатный благодаря кэшу.
Код, который должен выполниться один раз на загрузку приложения (инициализация аналитики, чтение токена), выносят на уровень модуля или прячут за переменной модуля, вне компонентов.
StrictMode также дважды вызывает тела компонентов, инициализаторы стейта и апдейтеры, чтобы поймать нечистый рендер, а в React 19 ещё и дважды запускает колбэк-рефы при монтировании.
Когда зависимость быстро меняется (ввод в поиске, переключение вкладок), в полёте оказывается несколько запросов, и более медленный старый ответ может прийти последним и перезаписать новый. Исправление: очистка, которая игнорирует или отменяет запрос предыдущего запуска.
Флаг ignore, выставленный в очистке, заставляет устаревший then ничего не делать; это работает с любым промисом.
AbortController к тому же отменяет сам сетевой запрос; ловите AbortError, чтобы он не выглядел как ошибка.
Та же очистка не даёт обновить стейт после размонтирования компонента, например после ухода на другую страницу.
В продакшен-приложениях берут TanStack Query, SWR, загрузчик роутера или Server Components, которые сами решают гонки, кэширование и дедупликацию; рукописный эффект годится для небольших случаев, и именно его проверяет этот вопрос.
Эффект не нужен, когда не задействовано ничего за пределами React: данные, которые можно вычислить из пропсов или стейта, вычисляют во время рендера, а работу, вызванную действием пользователя, кладут в обработчик события. Эффекты нужны для синхронизации с внешними системами.
Производные данные: const visible = todos.filter(...) во время рендера, а useMemo только если это медленно. Эффект плюс стейт дают два рендера и на один кадр показывают устаревшие данные.
Реакция на событие: отправляйте запрос, показывайте тост или делайте навигацию в onSubmit, а не в эффекте, который следит за флагом submitted.
Сброс стейта при смене пропа: дайте компоненту key вместо эффекта, который очищает стейт.
Уведомление родителя: вызывайте onChange в том же обработчике, который меняет стейт, а не в эффекте после него.
Цепочки эффектов, когда один эффект меняет стейт, запускающий другой: вычисляйте всё следующее состояние в одном обработчике.
Инициализация приложения: код на уровне модуля, а не эффект с [].
filters при каждом рендере оказывается новым объектом, поэтому зависимость всегда отличается по Object.is: эффект делает запрос, setProducts сохраняет новый массив, компонент перерисовывается, создаёт новый filters, и цикл повторяется.
Зависимости сравниваются по ссылке: объект, массив или функция, объявленные в теле компонента, считаются изменёнными при каждом рендере.
Исправление: создать объект внутри эффекта и зависеть от примитива, из которого он строится (category).
Если объект нужен в нескольких местах, мемоизируйте его через useMemo(() => ({ category, inStock: true }), [category]); React Compiler делает это автоматически.
В исправленной версии также добавлен флаг ignore, чтобы медленный ответ для старой категории не перезаписал более новый.
Классический вариант того же бага: эффект без массива зависимостей, который каждый раз кладёт в стейт новый объект.
Колбэк эффекта объявлен как async, поэтому возвращает промис, а всё, что возвращает эффект, React считает функцией очистки. При смене userId React вызывает этот промис как функцию, получает TypeError: destroy is not a function, и без error boundary размонтируется весь корень.
Что может вернуть эффект: undefined или функцию очистки. async-функция всегда возвращает промис, отсюда предупреждение при каждом запуске.
Когда ломается: первая загрузка работает; падение случается при первой очистке, когда меняется userId или компонент размонтируется. Под <StrictMode> в разработке очистка запускается сразу после монтирования, поэтому данные вообще не появляются.
Без очистки ещё и гонка: медленный ответ для предыдущего userId может прийти последним и перезаписать текущий.
Исправление: оставьте эффект синхронным, объявите async-функцию внутри него и вызовите её, а верните настоящую функцию очистки, которая ставит флаг ignore (или прерывает запрос через AbortController). Добавьте к load() обработчик .catch, иначе упавший запрос станет необработанным отказом.
TypeScript не пропустит исходный код: «Type 'Promise<void>' is not assignable to type 'void | Destructor'», поэтому этот баг попадает в прод из JavaScript-файлов или через any.
При монтировании: render 0, effect 0. После клика: render 1, cleanup 0, effect 1. Сначала идёт рендер, затем очищается старый эффект со своими значениями, затем запускается новый эффект.
Эффект выполняется после того, как его рендер закоммичен, и никогда во время рендера.
Очистка печатает 0, потому что замыкает рендер, в котором была создана, а не текущий стейт.
При размонтировании напечаталось бы только cleanup 1.
С <StrictMode> в режиме разработки монтирование выводит render 0 дважды, затем effect 0, cleanup 0, effect 0: компоненты рендерятся дважды, а эффекты проходят цикл запуск, очистка, запуск.
Классическая версия сохраняет значение в ref внутри эффекта, поэтому во время рендера ref ещё хранит значение из предыдущего коммита. Это работает, но читает ref.current во время рендера и возвращает значение из прошлого рендера, а не прошлое отличающееся значение.
Прошлый рендер, а не прошлое значение: если компонент перерисовался по другой причине, «предыдущее» значение становится равным текущему.
Рефы во время рендера: чтение ref.current в рендере как раз то, от чего предостерегают документация React и правила линтера React Compiler.
Альтернатива на стейте: хранить последнее увиденное значение в стейте и обновлять его во время рендера, когда вход изменился; React сразу перерисует компонент, ещё до его детей.
Часто не нужно ни то, ни другое: сравните старое и новое значение в обработчике события, который вызывает изменение.
Колбэк интервала создан в первом рендере и замкнул count = 0, поэтому каждый тик вызывает setCount(0 + 1); после первого тика стейт уже равен 1, и ничего не меняется.
Это устаревшее замыкание (stale closure): [] сообщает React, что эффект ничего не берёт из рендера, а это неправда, и exhaustive-deps об этом предупреждает.
Добавить count в зависимости можно, но тогда интервал будет пересоздаваться каждую секунду.
Функция-апдейтер setCount((c) => c + 1) получает актуальный стейт от React, поэтому эффект действительно ни от чего не зависит.
Чтобы читать актуальные пропсы в долгоживущем колбэке без его перезапуска, используйте useEffectEvent или ref.
useLayoutEffect выполняется синхронно после того, как React обновил DOM, но до того, как браузер отрисует кадр; useEffect обычно выполняется после отрисовки. Layout-версия нужна, только когда DOM надо измерить и поправить до того, как пользователь увидит кадр, например при позиционировании тултипа.
С useEffect пользователь может увидеть один кадр в неправильной позиции, а затем скачок (мерцание).
useLayoutEffect блокирует отрисовку, поэтому медленный код в нём делает интерфейс подвисающим; по умолчанию остаётся useEffect.
Обновления стейта внутри useLayoutEffect обрабатываются синхронно, до отрисовки.
При серверном рендеринге он не выполняется; там рендерят запасной вариант, пока клиент не сделает замер.
useInsertionEffect срабатывает ещё раньше, до layout-эффектов, и нужен библиотекам CSS-in-JS для вставки стилей.
useImperativeHandle(ref, () => handle) задаёт, что получит ref родителя от вашего компонента: небольшой объект с методами вместо сырого DOM-узла. Его используют для императивных действий, которые плохо выражаются пропсами, например focus, scrollToItem или play.
В React 19 ref приходит как проп; в старом коде компонент оборачивают в forwardRef.
Узкий хэндл прячет структуру DOM, поэтому разметку можно менять, не ломая вызывающий код.
Не используйте его для того, что умеют пропсы: проп isOpen лучше, чем modalRef.current.open(), потому что источником истины остаётся стейт родителя.
Передайте массив зависимостей третьим аргументом, если хэндл читает пропсы или стейт.
Пока меню открыто, слушайте pointerdown на document и закрывайте меню, если цель события находится вне его (!ref.current.contains(e.target)). Добавляйте слушатель в эффекте, который зависит от open, и снимайте его в функции очистки.
Вешайте ref на обёртку, в которую входит и кнопка-переключатель: иначе нажатие на кнопку считается кликом снаружи, слушатель закрывает меню, а onClick кнопки снова его открывает.
**pointerdown, а не click**: эффекты обновления, вызванного кликом, React выполняет синхронно, ещё до того, как клик дойдёт до document, поэтому слушатель click, добавленный в таком эффекте, получает и тот клик, который открыл меню; если ref висит только на панели, меню тут же закрывается. pointerdown к этому моменту уже прошёл, он покрывает мышь, касание и перо и совпадает с поведением нативных меню, которые закрываются по нажатию.
Порталы: меню, отрендеренное через createPortal, в DOM лежит вне обёртки, поэтому клики внутри него выглядят как клики снаружи; проверяйте второй ref на содержимом портала.
Пользователи клавиатуры: закрывайте меню по Escape и возвращайте фокус на кнопку.
Готовые варианты: атрибут popover даёт light dismiss (клик снаружи и Escape) силами браузера, а Radix, Headless UI или useDismiss из Floating UI закрывают пограничные случаи.
Отрендерите пустой контейнер с ref, создайте экземпляр библиотеки в эффекте, храните его в ref и уничтожайте в функции очистки. React владеет только контейнером; изменения пропсов доходят до экземпляра через его собственный API в отдельных эффектах, а не через пересоздание.
Создание после коммита: DOM-узел появляется только после того, как React его закоммитил, а рендер должен оставаться чистым, поэтому new Chart(canvas, config) пишут в эффекте, а не в теле компонента.
Уничтожение в очистке: chart.destroy(), map.remove(). В разработке StrictMode монтирует, размонтирует и снова монтирует компонент, и без очистки Chart.js бросает «Canvas is already in use», а Leaflet «Map container is already initialized».
Создать один раз, обновлять через API: эффект с [] создаёт экземпляр, другие передают изменения (chart.update(), map.setView(...)). Пересоздание при каждом изменении пропсов мерцает и теряет зум, сдвиг карты и анимации.
Не пускайте React в контейнер: не рендерьте детей в узел, которым управляет библиотека; оба начнут править один и тот же DOM, и это кончается ошибками вроде «Failed to execute 'removeChild' on 'Node'».
События библиотеки: подписывайтесь в эффекте и вызывайте последний проп через useEffectEvent, чтобы новый колбэк onMove не пересоздавал карту.
Серверный рендеринг: такие библиотеки часто обращаются к window при импорте, поэтому загружайте их только в браузере, например через import() внутри эффекта.
Вынесите нереактивную часть в событие эффекта: const onConnected = useEffectEvent(() => showNotification(theme)). Оно всегда видит актуальные пропсы и стейт, но не считается зависимостью, поэтому эффект перезапускается только из-за значений, на которые он действительно должен реагировать.
Пример: переподключаться к чату из-за смены theme неправильно; соединение зависит от roomId, а уведомление только читает theme.
События эффекта вызывают только изнутри эффектов, никогда во время рендера или из обработчиков, и не указывают в зависимостях; правило линтера о них знает.
useEffectEvent стабилен начиная с React 19.2. До него обходным путём был ref, который обновляли при каждом рендере (latest.current = theme) и читали внутри эффекта.
Заглушить exhaustive-deps комментарием неправильно: эффект начнёт читать устаревшие значения без всякого предупреждения.
Если значение вообще не реактивное, вынесите его за пределы компонента.
Колбэк-реф представляет собой функцию, переданную в ref: React вызывает её с DOM-узлом, когда узел прикрепляется, и до React 19 вызывал с null, когда узел открепляется. В React 19 колбэк может вернуть функцию очистки, и React вызывает её при откреплении вместо передачи null.
Колбэк-реф нужен, когда надо отреагировать на появление узла: измерить его, подключить ResizeObserver, передать его библиотеке не на React или собрать рефы динамического списка в Map из id в узел.
Инлайн-стрелка при каждом рендере становится новой функцией, поэтому React каждый раз её открепляет и прикрепляет заново (очистка, затем настройка); оберните её в useCallback, если настройка дорогая, или доверьте это React Compiler.
Возвращайте очистку или ничего: TypeScript теперь отклоняет неявный возврат вроде ref={(el) => (node = el)}, потому что любое возвращённое значение считается очисткой.
В <StrictMode> в режиме разработки React 19 прогоняет колбэк-рефы при монтировании по циклу запуск, очистка, запуск, так же как эффекты.