Skip to main content

Порядок рендерингу компонентів та виклику хуків у React

Порядок рендерингу компонентів у React - React обходить дерево компонентів у глибину при кожному оновленні, викликаючи кожну функцію компонента зверху вниз, а хуки всередині кожного компонента мають виконуватись у точно такому ж порядку при кожному рендері.

Теорія

TL;DR

  • Рендеринг схожий на сімейну вечерю: батько сідає за стіл першим, потім кличе дітей по одному, і тільки після них приходять сусіди по столу
  • React рендерить батька, іде вглиб до дітей, потім переходить до сусідів
  • Хуки ідентифікуються за позицією виклику у зв'язаному списку кожного компонента, а не за назвою
  • Якщо кількість хуків змінюється між рендерами, React видає помилку
  • Умовна логіка йде всередину хука, а не навколо нього

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

jsx
function Parent() { console.log('Parent render'); // 1-й return <Child />; } function Child() { console.log('Child render'); // 2-й const [count, setCount] = useState(0); // Хук 1 - завжди позиція 0 const ref = useRef(null); // Хук 2 - завжди позиція 1 useEffect(() => { console.log('Child effect'); // 4-й - після відмалювання браузером }, []); console.log('Child hooks done'); // 3-й return <div ref={ref}>{count}</div>; } // Вивід у консоль: // Parent render // Child render // Child hooks done // Child effect

Спочатку рендериться Parent, потім синхронно Child, потім ефекти виконуються після відмалювання браузером. Ця послідовність фіксована.

Порядок рендерингу: обхід у глибину

React обходить JSX-дерево зверху вниз. Для такої структури:

jsx
function App() { return ( <> <SiblingA /> <Parent /> <SiblingB /> </> ); } function Parent() { return <><ChildA /><ChildB /></>; } function ChildA() { console.log('ChildA'); return null; } function ChildB() { console.log('ChildB'); return null; } function SiblingA() { console.log('SiblingA'); return null; } function SiblingB() { console.log('SiblingB'); return null; } // Вивід: App -> SiblingA -> Parent -> ChildA -> ChildB -> SiblingB

React іде вглиб кожного піддерева перед переходом до наступного сусіда. SiblingB не починає рендеритись, доки не завершиться все піддерево Parent.

Це важливо при налагодженні. Якщо ChildA повільний, він блокує ChildB і SiblingB. console.log на початку кожного компонента показує цю послідовність точно. На практиці цей прийом виявляє більше несподіваних ре-рендерів, ніж більшість сесій профілювання.

Як React відстежує стан хуків

Кожен компонент у Fiber-архітектурі має свій зв'язаний список станів хуків. Три хуки в компоненті означає, що React зберігає їх на позиціях 0, 1 і 2 у цьому списку.

При повторному рендері React відтворює ці виклики в тому ж порядку і читає позиції 0, 1, 2, щоб отримати попередні значення. Жодних назв. Жодної магії. Тільки індекс позиції.

Саме тому порядок хуків не може змінюватись. Пропущений хук 1 переміщує хук 2 на позицію 0. React читає неправильний стан. Баг часто непомітний, поки щось не зламається в продакшені.

Фази виконання хуків

Хуки поділяються на дві групи за часом виконання.

Під час рендеру: useState, useReducer, useContext, useRef, useMemo, useCallback. Виконуються синхронно під час виклику функції компонента, в порядку оголошення.

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

Функції очищення (cleanup) виконуються у зворотному порядку при розмонтуванні: спочатку очищаються діти, потім батько.

Коли це знання потрібне

  • Налагодження зайвих ре-рендерів: додай console.log на початку кожного компонента, перевір, які з них спрацьовують без потреби
  • Кастомні хуки: завжди викликай їх на верхньому рівні, ніколи всередині циклів або умов - кастомний хук це функція що викликає інші хуки, ті самі правила
  • Продуктивність: обгортай піддерева в React.memo, щоб ре-рендер батька не каскадував у дітей, які не залежать від зміненого стану
  • useLayoutEffect проти useEffect: якщо бачиш мерехтіння при оновленні розміру або позиції елемента, переходь з useEffect на useLayoutEffect - це запобігає відмалюванню браузером проміжного стану

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

Умовний виклик хука:

jsx
// Неправильно function Bad({ show }) { if (show) { const [count, setCount] = useState(0); // пропускається коли show false } return <div />; } // Правильно function Good({ show }) { const [count, setCount] = useState(0); // викликається завжди return show ? <div>{count}</div> : null; }

Коли show змінюється з true на false, хук на позиції 0 зникає. React читає значення наступного хука на неправильне місце. Результат - неправильний стан або помилка "Rendered fewer hooks than expected".

Хук всередині циклу:

jsx
// Неправильно function BadList({ items }) { items.forEach(item => { const [state, setState] = useState(0); // кількість змінюється з items.length }); } // Правильно function GoodList({ items }) { const [states, setStates] = useState(() => items.map(() => 0)); // один useState тримає весь масив }

React видає "Rendered more hooks than during the previous render", коли змінюється items.length. Один useState з масивом вирішує проблему.

Ранній return перед хуками:

jsx
// Неправильно function Bad({ user }) { if (!user) return null; // хуки нижче не викликаються при цьому рендері const [active, setActive] = useState(false); return <div>{active}</div>; } // Правильно function Good({ user }) { const [active, setActive] = useState(false); // виконується завжди if (!user) return null; return <div>{active}</div>; }

Умова всередині кастомного хука:

jsx
// Неправильно function useData(id) { if (!id) return null; const [data, setData] = useState(null); // умовний виклик всередині хука } // Правильно function useData(id) { const [data, setData] = useState(null); useEffect(() => { if (id) fetchData(id).then(setData); }, [id]); return data; }

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

  • Next.js: серверний рендеринг і гідратація залежать від збігу порядку рендерингу між сервером і клієнтом. Розбіжність у хуках спричиняє помилки гідратації, які важко відстежити
  • TanStack Query: useQuery треба викликати на верхньому рівні - ключ кешу залежить від стабільної позиції виклику
  • Redux Toolkit: useSelector у підключених компонентах підпорядковується тому ж механізму зв'язаного списку
  • React DevTools Profiler: показує порядок і час рендерингу кожного компонента в дереві
  • Concurrent React 18: фаза рендеру залишається синхронною і обходить дерево у глибину; фаза фіксації може поступатися для пріоритетної роботи, але порядок компонентів і хуків всередині одного проходу рендеру незмінний

Follow-up питання

Q: Який порядок рендерингу для <Parent><A/><B/></Parent><Sibling/>?
A: Parent -> A -> B -> Sibling. React завершує все піддерево Parent перед переходом до Sibling.

Q: Чому не можна викликати useState всередині if?
A: React ідентифікує хуки за індексом виклику у зв'язаному списку файбера. Пропуск виклику зміщує всі наступні індекси, і React читає неправильні значення при наступному рендері.

Q: Який порядок очищення при розмонтуванні компонентів?
A: Зворотний порядку монтування. Спочатку очищаються діти, потім батьки. Це відповідає порядку фіксації ефектів, знизу вгору.

Q: Чим відрізняється useLayoutEffect від useEffect за часом виконання?
A: useLayoutEffect виконується синхронно після мутації DOM, але до відмалювання браузером. useEffect виконується після відмалювання. Використовуй useLayoutEffect, коли потрібно читати або змінювати розміри DOM до того, як користувач побачить результат.

Q: Чи може порядок рендерингу змінитися в concurrent mode React 18?
A: Порядок обходу дерева у глибину залишається незмінним. Concurrent mode може призупиняти і відновлювати роботу рендеру за пріоритетом, але всередині одного проходу послідовність виклику компонентів і хуків ідентична попереднім версіям React.

Q (senior): Компонент ре-рендериться зі скороченим списком. Хуки правильно оголошені поза циклом. Але обробники useCallback досі посилаються на старі значення елементів. Чому?
A: Застарілі замикання (stale closures). useCallback захоплює змінні з рендеру, під час якого callback був останній раз створений. Якщо масив залежностей не включає змінені значення, callback тримає посилання на старий стан. Рішення - додати ці значення до масиву залежностей useCallback, або зберігати останнє значення в ref без тригеру ре-рендеру.

Приклади

Базовий: порядок рендерингу в дереві компонентів

jsx
function App() { console.log('App'); return ( <> <Header /> <Main /> <Footer /> </> ); } function Main() { console.log('Main'); return ( <> <Sidebar /> <Content /> </> ); } function Header() { console.log('Header'); return <header />; } function Sidebar() { console.log('Sidebar'); return <aside />; } function Content() { console.log('Content'); return <main />; } function Footer() { console.log('Footer'); return <footer />; } // Вивід: // App // Header // Main // Sidebar // Content // Footer

Header завершується до початку Main. Sidebar і Content обидва завершуються до початку Footer. Обхід у глибину: сусіди рендеряться після всіх дітей батька.

Середній: хуки в реальному компоненті дашборду

jsx
function UserDashboard({ userId }) { // Всі хуки оголошуються безумовно на початку const [user, setUser] = useState(null); // Хук 1: завжди позиція 0 const [stats, setStats] = useState({}); // Хук 2: завжди позиція 1 const containerRef = useRef(null); // Хук 3: завжди позиція 2 useEffect(() => { if (userId > 0) { fetchUser(userId).then(setUser); // умова всередині, а не навколо хука } }, [userId]); if (!userId) return null; // ранній return ПІСЛЯ всіх хуків return ( <div ref={containerRef}> <UserProfile user={user} /> <StatsPanel data={stats} /> </div> ); } // Порядок рендерингу: UserDashboard -> UserProfile -> StatsPanel // Порядок хуків при кожному рендері: useState(null), useState({}), useRef, useEffect

Всі три хуки виконуються при кожному рендері незалежно від userId. Умова переміщена всередину useEffect. Ранній return стоїть після хуків. Саме такий патерн очікує React.

Просунутий: діагностика та виправлення помилки порядку хуків

jsx
// Помилка: кількість хуків змінюється разом з items.length function BadList({ items }) { const renderedItems = items.map(item => { const [selected, setSelected] = useState(false); // неправильно: різна кількість щоразу return ( <li key={item.id} onClick={() => setSelected(s => !s)}> {item.name} {selected ? '(вибрано)' : ''} </li> ); }); return <ul>{renderedItems}</ul>; } // Помилка React: "Rendered more hooks than during the previous render." // Рішення: один useReducer на верхньому рівні, стабільна кількість хуків function GoodList({ items }) { const [selected, dispatch] = useReducer( (state, id) => ({ ...state, [id]: !state[id] }), {} ); return ( <ul> {items.map(item => ( <li key={item.id} onClick={() => dispatch(item.id)}> {item.name} {selected[item.id] ? '(вибрано)' : ''} </li> ))} </ul> ); }

Переміщення стану кожного елемента в цикл порушує правило фіксованої кількості хуків. Один useReducer на верхньому рівні обробляє всі елементи, тримає кількість хуків стабільною на рівні 1 незалежно від розміру списку, і уникає краху при додаванні або видаленні елементів.

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

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

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

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