Вы когда-нибудь создавали всплывающую подсказку, которая резко перемещается из верхнего левого угла на свое правильное место? Или модальное окно, которое на мгновение принимает неверный размер, прежде чем встать на место? Этот секундный глюк — мерцание верстки (layout flicker). Оно происходит, когда React считывает DOM, вычисляет исправление и обновляет состояние, но браузер уже начал отрисовывать пиксели на экране. Обычное решение — заменить useEffect на useLayoutEffect. Этот прием работает, но только если вы точно понимаете, в какой момент каждый хук срабатывает в конвейере браузера.

Конвейер браузера: Render, Commit, Paint

React обновляет компонент в три этапа. На фазе render React строит — или перестраивает — Virtual DOM и вычисляет разницу (diff). Никаких реальных изменений пикселей еще нет; это чистые вычисления в памяти. Затем наступает фаза commit, где React применяет эти изменения к реальным узлам DOM. Обновляются стили, узлы вставляются или удаляются, меняется текст.

Затем управление переходит к браузеру. На фазе paint движок рендеринга браузера вычисляет геометрию макета и рисует пиксели на экране. Эта последовательность строго определена. Браузер должен завершить расчет макета (layout), прежде чем сможет выполнить отрисовку (paint), и он должен закончить отрисовку, прежде чем пользователь увидит что-либо новое. Разрыв между commit и paint измеряется миллисекундами, но он реален, и именно в этом промежутке пути useEffect и useLayoutEffect расходятся.

Почему useEffect вызывает мерцание

useEffect выполняется асинхронно; он запланирован к выполнению после того, как браузер уже отрисовал экран. DOM обновлен, пиксели нарисованы, и только тогда React вступает в дело, чтобы запустить ваш эффект.

Представьте, что вы рендерите выпадающее меню под кнопкой. Внутри useEffect вы вызываете buttonRef.current.getBoundingClientRect(), вычисляете правильные координаты top и left и сохраняете их в состоянии. Поскольку useEffect запускается после отрисовки (paint), браузер уже нарисовал выпадающий список в его позиции по умолчанию, возможно, с top: 0, left: 0. Только после этой отрисовки ваш эффект обновит состояние. React применяет исправленные координаты, и браузер рисует снова. Пользователь видит два кадра: сначала неверное положение, затем правильное. Этот визуальный скачок и есть то самое мерцание, которого все пытаются избежать.

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

Как useLayoutEffect блокирует отрисовку

useLayoutEffect выполняется синхронно, сразу после того, как React изменил DOM, но до того, как у браузера появится возможность рассчитать макет или отрисовать пиксели. Он полностью блокирует конвейер отрисовки.

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

Такое блокирующее поведение является одновременно и преимуществом, и риском. Поскольку useLayoutEffect не дает браузеру выполнить отрисовку до завершения своей работы, любые тяжелые вычисления внутри него замораживают пользовательский интерфейс. Даже несколько десятков миллисекунд заблокированной отрисовки ощущаются пользователем как «фризы» (jank). Вот почему в документации React прямо советуют начинать с useEffect и переходить на useLayoutEffect только тогда, когда вы действительно заметите нетерпимое мерцание.

Когда использовать каждый хук

Большая часть вашей логики должна находиться в useEffect. Используйте его для:

  • Получения данных из API
  • Настройки подписок или слушателей событий
  • Отправки событий аналитики
  • Любого побочного эффекта, который не считывает и не изменяет макет немедленно

Зарезервируйте useLayoutEffect для операций, которые должны считать DOM и записать данные обратно до того, как пользователь увидит кадр:

  • Измерение размеров элементов, таких как ширина, высота или позиция прокрутки
  • Вычисление координат для тултипов, поповеров или контекстных меню
  • Предотвращение заметных сдвигов верстки, когда визуальное положение зависит от отрисованной геометрии

Если вы не уверены, что выбрать, по умолчанию используйте useEffect. Переходите на useLayoutEffect только тогда, когда заметите визуальную нестабильность. Одно это правило позволит подавляющему большинству React-приложений работать плавно.

Подвох при Server-Side Rendering

Если вы используете Next.js, Remix или любой другой фреймворк, который рендерит React на стороне сервера, вы столкнетесь с предупреждением при использовании useLayoutEffect. Поскольку у сервера нет DOM, хуку нечего измерять. React предупреждает вас о том, что он ожидал браузерную среду, но не нашел ее. Во время гидратации это несоответствие также может вызвать трудноуловимые баги, так как серверная разметка и первый рендер на стороне клиента могут различаться.

Стандартное решение — это изоморфный хук, который выбирает нужный эффект в зависимости от среды:

const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

Используйте эту обертку в любом компоненте, которому необходимо измерять DOM-узлы, но который может выполняться во время серверного рендеринга. Она подавляет предупреждение и обеспечивает согласованность серверного вывода.

Производительность и лучшие практики

Поскольку useLayoutEffect блокирует отрисовку, старайтесь делать тело хука максимально легким. Считайте значение макета, вычислите поправку и запишите её обратно. Не загружайте данные, не парсите большие объекты и не запускайте ресурсоемкие алгоритмы внутри него. Тяжелый код здесь заблокирует основной поток и приведет к тому, что интерфейс будет казаться «зависшим».

При измерении элементов используйте React refs вместо document.getElementById. Refs привязаны к экземпляру вашего компонента, сохраняются при ререндерах без использования хитростей с запросами и надежно работают с порталами или условным рендерингом. Глобальный поиск по ID нарушает инкапсуляцию компонента и может вернуть null именно в тот момент, когда он вам необходим.

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

Главный вывод: Начинайте со useEffect для всего. Как только вы заметите, что тултип или модальное окно на мгновение появляется в неправильном месте, прежде чем исправиться, — это ваш сигнал. Переходите на useLayoutEffect, измеряйте DOM, корректируйте макет и позволяйте браузеру выполнить отрисовку один раз — правильно.