Як працює 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-переходи.
Швидкий приклад
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 заради них - зайві витрати.
Таблиця порівняння
| Аспект | useLayoutEffect | useEffect |
|---|---|---|
| Час запуску | Синхронно, після коміту, до 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
// Неправильно - блокує paint без жодної причини
useLayoutEffect(() => {
fetch('/api/data').then(setData);
}, []);
// Правильно
useEffect(() => {
fetch('/api/data').then(setData);
}, []);Fetch все одно асинхронний. useLayoutEffect не зробить його швидшим, але затримає перший paint.
Ігнорування серверного рендерингу
// Виводить попередження при 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.
Нестабільні залежності викликають нескінченний цикл
// Нескінченний цикл - новий об'єкт на кожному рендері
useLayoutEffect(() => {
applyStyle(elementRef.current, style);
}, [style]); // style = { color: 'red' } створюється заново щоразу
// Виправлення через useMemo
const style = useMemo(() => ({ color: 'red' }), []);
useLayoutEffect(() => {
applyStyle(elementRef.current, style);
}, [style]);Це не унікальна проблема useLayoutEffect, але синхронне батчінг-ре-рендерів робить цикл щільнішим і складнішим для відладки.
Анімації, які оновлюються на кожному кадрі
// Блокує 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} і тільки потім стрибне туди, де треба.
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
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 є правильним вибором.
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 на час мережевого запиту - додаткова затримка без жодної користі.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.