Skip to main content

Як працює useLayoutEffect у React і чим він відрізняється від useEffect?

useLayoutEffect - хук React, який запускається синхронно після того, як React зафіксував зміни в DOM, але до того, як браузер намалював екран. useEffect запускається асинхронно після рендерингу. Ця різниця у часі визначає все.

Теорія

TL;DR

  • useLayoutEffect - це як перевірити лист перед тим, як запечатати конверт. useEffect - це перевірити, що ти надіслав, вже після доставки.
  • Головна відмінність: useLayoutEffect блокує відображення браузера до завершення. useEffect - ні.
  • Потрібно прочитати або змінити DOM до того, як користувач це побачить? useLayoutEffect. Запити до API, підписки, аналітика? useEffect.
  • useLayoutEffect не виконується на сервері (SSR). useEffect працює нормально.
  • У React 18 useLayoutEffect досі запускається з найвищим пріоритетом і може блокувати concurrent-переходи.

Швидкий приклад

jsx
import { useEffect, useLayoutEffect, useRef } from "react"; function Demo() { const ref = useRef(null); useLayoutEffect(() => { // Запускається ДО відображення - користувач не бачить цей стан if (ref.current) ref.current.style.background = "red"; }); useEffect(() => { // Запускається ПІСЛЯ відображення - перезаписує layout effect if (ref.current) ref.current.style.background = "blue"; }); return <div ref={ref} style={{ width: 100, height: 100, background: "green" }} />; } // Що бачить користувач: синій блок. // Порядок: зелений (JSX) -> червоний (layout effect, до paint) -> синій (effect, після paint). // Миготіння відсутнє - користувач бачить тільки синій.

Обидва ефекти спрацьовують на кожному рендері (без масиву залежностей). Layout effect встановлює червоний, але ця зміна не потрапляє на екран, бо відображення ще заблоковане. Звичайний ефект спрацьовує після paint і перезаписує колір на синій.

Ключова відмінність

useLayoutEffect підключається до коміт-фази React у commitLayoutEffects і виконується синхронно, блокуючи requestAnimationFrame до завершення. Тобто ти можеш викликати getBoundingClientRect() і отримати актуальні значення поточного DOM. useEffect планується як пасивний ефект з нижчим пріоритетом, вже після того, як браузер відобразив зміни. Будь-яке звернення до DOM всередині useEffect може отримати вже застарілі дані - звідси і характерне миготіння тултипів або дропдаунів при першому рендері.

Коли використовувати useLayoutEffect, а коли useEffect

  • Виміряти розміри елементів до того, як користувач їх побачить: useLayoutEffect. Запобігає стрибку елемента з одної позиції в іншу.
  • Прочитати позицію скролу або виділення тексту після коміту: useLayoutEffect. Дані відповідають поточному стану DOM, а не застарілому знімку.
  • Позиціонування тултипів, дропдаунів, поповерів: useLayoutEffect. Читання і запис layout в одному синхронному вікні усуває миготіння.
  • Запити до API, підписки, аналітика, таймери: useEffect. Їм не потрібен доступ до DOM до відображення. Блокувати paint заради них - зайві витрати.

Таблиця порівняння

АспектuseLayoutEffectuseEffect
Час запускуСинхронно, після коміту, до paintАсинхронно, після paint
Блокує відображення?ТакНі
Підходить дляВимірювання DOM, корекція стилівAPI-запити, підписки, логування
Поведінка при SSRПропускається (немає DOM)Виконується нормально
Вартість для продуктивностіВища (блокує UI-потік)Нижча (неблокуючий)
Strict Mode (dev)Подвійний викликПодвійний виклик
React 18 concurrent modeНайвищий пріоритет, може блокувати переходиПасивний, відкладений

Як React планує ці ефекти всередині

Файбер-рекончайлер React запускає useLayoutEffect у commitLayoutEffects з пріоритетом ImmediateSchedulerPriority. Це синхронний цикл по fiber-деревах, який завершується ще до того, як requestAnimationFrame отримує свій слот. useEffect потрапляє в commitPassiveMountEffects через scheduleCallback(NormalSchedulerPriority) - пасивна черга, браузер спочатку малює, і тільки потім React обробляє пасивні ефекти.

Але є нюанс. У React 18 concurrent mode useLayoutEffect досі запускається з максимальним пріоритетом. Важка логіка всередині нього може затримати переходи з useTransition, бо він не поступається місцем відкладеним оновленням.

На практиці я бачив кодові бази, де useLayoutEffect використовували для запитів до API «на всяк випадок». Мережевий запит асинхронний в будь-якому разі, тому це нічого не прискорює. Але блокує paint на кожному рендері. Відкрий вкладку Performance в Chrome DevTools, запиши один рендер - побачиш це blocking time прямо в таймлайні.

Типові помилки

Запити до API всередині useLayoutEffect

jsx
// Неправильно - блокує paint без жодної причини useLayoutEffect(() => { fetch('/api/data').then(setData); }, []); // Правильно useEffect(() => { fetch('/api/data').then(setData); }, []);

Fetch все одно асинхронний. useLayoutEffect не зробить його швидшим, але затримає перший paint.

Ігнорування серверного рендерингу

jsx
// Виводить попередження при SSR - немає DOM на сервері useLayoutEffect(() => { document.title = 'My App'; }, []); // Безпечні варіанти: useEffect(() => { document.title = 'My App'; }, []); // Або з перевіркою: useLayoutEffect(() => { if (typeof window === 'undefined') return; document.title = 'My App'; }, []);

Next.js і Remix пропускають useLayoutEffect на сервері, але виводять попередження в dev-режимі. Якщо бачиш таке попередження - або переходь на useEffect, або додай перевірку typeof window.

Нестабільні залежності викликають нескінченний цикл

jsx
// Нескінченний цикл - новий об'єкт на кожному рендері useLayoutEffect(() => { applyStyle(elementRef.current, style); }, [style]); // style = { color: 'red' } створюється заново щоразу // Виправлення через useMemo const style = useMemo(() => ({ color: 'red' }), []); useLayoutEffect(() => { applyStyle(elementRef.current, style); }, [style]);

Це не унікальна проблема useLayoutEffect, але синхронне батчінг-ре-рендерів робить цикл щільнішим і складнішим для відладки.

Анімації, які оновлюються на кожному кадрі

jsx
// Блокує requestAnimationFrame при кожній зміні value useLayoutEffect(() => { gsap.to(element, { x: 100 }); }, [value]); // Краще - нехай бібліотека анімацій керує своїм циклом useEffect(() => { gsap.to(element, { x: 100 }); }, [value]);

Де використовується в реальних проектах

  • React Window / React Virtual: вимірює висоту рядків до віртуалізації скролу через useLayoutEffect, щоб перший рендер показував правильні зміщення.
  • Framer Motion: читає DOM-bounds до відображення для layout-анімацій (пропc layout всередині).
  • Floating UI / React Tooltip: позиціонування тултипів і поповерів завжди через useLayoutEffect - елементи з'являються в правильних координатах одразу.
  • TanStack Table: вимірювання ширини колонок при resize через useLayoutEffect, щоб уникнути миготіння під час перетягування.
  • React Spring: layout effects для синхронного читання стилів в фізичних анімаціях.

Питання для співбесіди

Q: Який точний порядок виконання useLayoutEffect і useEffect з урахуванням cleanup при повторному рендері?
A: При монтуванні: спочатку layout effect, потім passive effect. При ре-рендері: cleanup layout effect, layout effect, cleanup passive effect, passive effect. Layout завжди перед passive. Це важливо, якщо один ефект залежить від стану, який встановлює інший.

Q: Що відбувається з useLayoutEffect при SSR у Next.js?
A: React пропускає його і виводить попередження в dev-режимі. Компонент рендериться, але ефект на сервері не виконується. Для серверно-безпечних side effects використовуй useEffect.

Q: Скільки разів спрацьовує useLayoutEffect у React 18 Strict Mode?
A: Двічі в режимі розробки. React монтує, запускає ефект, запускає cleanup, потім монтує знову. Це виявляє відсутню логіку очищення. У продакшені - один раз.

Q: Що таке useInsertionEffect і чим він відрізняється?
A: useInsertionEffect (React 18+) запускається ще до мутацій DOM - він розроблений для CSS-in-JS бібліотек, які вставляють стилі. Читати layout у ньому не можна. useLayoutEffect запускається після мутацій і може читати layout. Для роботи з DOM useLayoutEffect залишається правильним вибором.

Q (senior): Чи може useLayoutEffect заблокувати concurrent-переходи в React 18?
A: Так. Він виконується з пріоритетом ImmediateSchedulerPriority, тобто до того, як React поступиться місцем відкладеним оновленням від useTransition. Важка синхронна логіка всередині затримає завершення переходу і помітно сповільнить UI.

Приклади

Позиціонування тултипу без миготіння

Канонічний кейс. Без useLayoutEffect тултип на мить з'явиться в позиції {top: 0, left: 0} і тільки потім стрибне туди, де треба.

jsx
import { useLayoutEffect, useState, useRef } from "react"; function Tooltip({ children, text }) { const [position, setPosition] = useState({ top: 0, left: 0 }); const triggerRef = useRef(null); const tooltipRef = useRef(null); useLayoutEffect(() => { const trigger = triggerRef.current.getBoundingClientRect(); const tooltip = tooltipRef.current.getBoundingClientRect(); // Розраховуємо позицію до відображення - стрибка не буде setPosition({ top: trigger.bottom + 8, left: Math.max(8, trigger.left + (trigger.width - tooltip.width) / 2), }); }, []); return ( <> <span ref={triggerRef}>{children}</span> <div ref={tooltipRef} style={{ position: "fixed", top: position.top, left: position.left, background: "#111", color: "#fff", padding: "4px 8px", borderRadius: 4, }} > {text} </div> </> ); }

Обидва rect читаються синхронно, позиція розраховується, і React ре-рендерить з правильними координатами до відображення. Бібліотека Floating UI використовує цей самий підхід всередині.

Визначення переповнення тексту до першого paint

jsx
import { useState, useLayoutEffect, useRef } from "react"; function TruncatedLabel({ text }) { const [isOverflowing, setIsOverflowing] = useState(false); const ref = useRef(null); useLayoutEffect(() => { if (ref.current) { // scrollWidth > clientWidth означає, що текст обрізається setIsOverflowing(ref.current.scrollWidth > ref.current.clientWidth); } }, [text]); return ( <span ref={ref} title={isOverflowing ? text : undefined} style={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 200, }} > {text} </span> ); }

Якби тут був useEffect, компонент спочатку відобразився б без атрибута title, і тільки потім додав його. З useLayoutEffect вимірювання і ре-рендер відбуваються до першого paint - користувач бачить одразу правильний стан.

useEffect для звичайного кейсу

Не кожна задача потребує useLayoutEffect. Якщо немає вимірювань або мутацій DOM до відображення - useEffect є правильним вибором.

jsx
import { useState, useEffect } from "react"; function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { let cancelled = false; fetch(`/api/users/${userId}`) .then((r) => r.json()) .then((data) => { if (!cancelled) setUser(data); }); return () => { cancelled = true; }; }, [userId]); if (!user) return <p>Завантаження...</p>; return <p>{user.name}</p>; }

Запиту до API не потрібен доступ до DOM до відображення. useLayoutEffect тут заблокував би перший paint на час мережевого запиту - додаткова затримка без жодної користі.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?