Причини повторного рендерингу компонентів у React
Перерендеринг - це коли React викликає функцію компонента знову, будує нове віртуальне DOM-дерево і порівнює його з попереднім.
Теорія
TL;DR
- Зміна стану завжди ставить ре-рендер у чергу, навіть якщо значення виглядає однаково
- Перерендеринг батька каскадно передається всім дочірнім компонентам, якщо їх не захистити
React.memo - Зміна контексту перерендерить кожного споживача в дереві, а не тільки найближчого
- Аналогія: перерендеринг схожий на те, як кухня в ресторані передруковує талон замовлення. Нове замовлення (стан), крик менеджера (перерендеринг батька), або особливий запит (контекст) - все це запускає новий друк, щоб перевірити що змінилось
- Правило: запусти React DevTools Profiler перш ніж додавати будь-яку мемоізацію
Швидкий приклад
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
console.log('Counter рендериться'); // логується при кожному рендері
return (
<button onClick={() => setCount(count + 1)}>
Кліків: {count}
</button>
);
}
// Кожен клік → setCount → компонент запускається знову → логКлік на кнопку викликає setCount, React перезапускає Counter, будує нове дерево, порівнює і оновлює тільки текст кнопки. Ось і весь цикл.
Що запускає перерендеринг
Зміна стану. Виклик setCount або будь-якого сеттера ставить ре-рендер у чергу. React 18 батчить кілька змін стану в одному обробнику події в один рендер, тому два setState в одному кліку дадуть один рендер, не два.
Зміна пропсів. За замовчуванням будь-який перерендеринг батька перезапускає всі дочірні компоненти, навіть якщо пропси не змінились. React не порівнює пропси перед викликом дочірнього компонента. Саме тому існує React.memo.
Перерендеринг батька. Це найпоширеніша причина несподіваних ре-рендерів у продакшені. Оновлення лічильника в батьківському компоненті змушує всіх дітей перезапускатись, навіть тих, що не отримують жодних пропсів.
Зміна контексту. Кожен компонент, що викликає useContext(MyContext), перерендериться при зміні значення контексту. Якщо передавати новий літерал об'єкта в провайдер при кожному рендері (value={{ theme: 'dark' }}), це нове посилання кожного разу, тому всі споживачі перерендеряться, навіть якщо дані однакові.
Диспатч через useReducer. Виклик dispatch завжди тригерить перерендеринг компонента з редюсером і далі каскадно вниз по дереву.
Головна різниця: перерендеринг і DOM-коміт
Перрендеринг і оновлення DOM - це два окремих кроки. React викликає функцію компонента (перерендеринг), будує нове fiber-дерево, порівнює з попереднім, і тільки потім записує мінімальний набір змін у реальний DOM (коміт). Компонент може перерендеритись десятки разів без жодних DOM-мутацій, якщо результат однаковий. Саме тому React DevTools Profiler показує кількість рендерів окремо від часу малювання.
Коли оптимізувати
- Повільні рядки в довгих списках - обгортай рядки в
React.memo, стабілізуй дані черезuseMemo - Колбеки в пропсах, що ламають memo - обгортай їх у
useCallback, щоб посилання залишалось стабільним між рендерами батька - Контекст, що надто широко тригерить ре-рендери - розбий один великий контекст на менші або обгортай значення в
useMemo - Обчислення, що перезапускаються при кожному рендері - кешуй через
useMemo - Перед усім цим - запусти React DevTools Profiler і переконайся, що проблема реальна. Оптимізація без вимірювань - це здогади
Як React обробляє перерендеринги всередині
Планувальник React ставить fiber-вузли в чергу при зміні стану або пропсів. Рекончайлер проходить по fiber-дереву, порівнює попередні та нові пропси через shallow-рівність і позначає вузли для оновлення. У React 18 автоматичний батчинг (automatic batching) групує кілька змін стану в асинхронних контекстах (проміси, setTimeout) в один прохід. Фаза коміту потім записує тільки diff-зміни в реальний DOM. Тому 50 ре-рендерів можуть дати всього 3 DOM-оновлення.
Типові помилки
Мутація стану напряму
const [arr, setArr] = useState([1, 2]);
arr.push(3);
setArr(arr); // те саме посилання → React пропускає перерендерингReact порівнює посилання через Object.is. Те саме посилання означає відсутність оновлення. Виправлення: setArr([...arr, 3]) - новий масив з новим посиланням.
Інлайн-об'єкти або функції в пропсах ламають memo
const Child = React.memo(({ user }) => <div>{user.name}</div>);
function Parent() {
const [count, setCount] = useState(0);
const user = { name: 'Alice' }; // новий об'єкт при кожному рендері
return <Child user={user} />;
}
// Child рендериться при кожному ре-рендері Parent, незважаючи на React.memoReact.memo робить shallow-порівняння. { name: 'Alice' } !== { name: 'Alice' } - різні посилання. Виправлення: const user = useMemo(() => ({ name: 'Alice' }), []);
Літерал об'єкта як значення контексту
<MyContext.Provider value={{ theme: 'dark' }}>При кожному рендері батька створюється новий об'єкт. Всі споживачі перерендеряться. Виправлення: обгорни в useMemo або перенеси значення в стан.
Думати, що useEffect запобігає ре-рендерам
useEffect запускається після рендеру, не замість нього. Додавання ефекту не пропускає цикл рендерингу. Щоб уникнути зайвих рендерів, мемоізуй компонент або стабілізуй його пропси.
Інлайн-стрілочні функції в пропсах
<Child onClick={() => doSomething()} />При кожному рендері батька створюється нове посилання на функцію. Якщо Child обгорнутий у React.memo, він все одно перерендериться, бо проп завжди виглядає новим. Виправлення: const handleClick = useCallback(() => doSomething(), []);
Де зустрічається в реальному коді
- React TodoMVC - масиви в стані тригерять рендер всього списку;
React.memoна рядках для 1000+ елементів - Redux Toolkit - Immer створює нові посилання на стан при кожній дії, тому
useSelectorпотребує точних селекторів - Next.js - зміна роутера передає нові пропси в компоненти сторінок;
useSWRстабілізує посилання на дані - React Query - мутації кешу повідомляють тільки підписані observer-и запитів, а не все дерево
- Material-UI - зміна теми каскадно торкається всіх styled-компонентів; стабільні селектори в
makeStylesобмежують це
Питання на співбесіді
Q: Мій мемоізований компонент все одно рендериться. Чому?
A: Перевір нестабільні посилання в пропсах: інлайн-функції, інлайн-об'єкти, значення контексту без useMemo. React DevTools Profiler покаже "why did this render" для кожного компонента.
Q: Стан не змінився, але компонент перерендерився. Чому?
A: Найімовірніше - перерендеринг батька. Обгорни компонент у React.memo і переконайся, що всі його пропси мають стабільні посилання.
Q: Яка різниця між перерендерингом і DOM-комітом?
A: Перерендеринг - це виклик функції компонента React. DOM-коміт - це запис змін у браузер. Можна мати багато рендерів і мало DOM-мутацій, якщо результат однаковий.
Q: Як автоматичний батчинг React 18 впливає на рендери?
A: До React 18 кожен setState у setTimeout або Promise.then давав окремий рендер. React 18 автоматично батчить всі в один прохід. Для неважливих оновлень є startTransition, щоб UI залишався чуйним під час важких рендерів.
Q: Як оптимізувати список з 10 000 елементів, що рендериться повільно?
A: Комбінуй windowing (react-window або react-virtual), React.memo на компонентах рядків і useMemo на трансформації даних. Windowing дає найбільший ефект, бо взагалі прибирає більшість DOM-вузлів з дерева.
Приклади
Базовий: стан запускає перерендеринг
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
console.log('рендеринг'); // запускається при кожному кліку
return (
<button onClick={() => setCount(count + 1)}>
Кліків: {count}
</button>
);
}Кожен клік передає нове значення setCount. React перезапускає Counter, логує і оновлює текст кнопки. Якщо викликати setCount(count) з тим самим значенням, React виконає один рендер і зупиниться.
Середній: каскадний рендер без memo і з ним
function TodoList() {
const [todos, setTodos] = useState([]);
// порожній масив залежностей працює, бо використовується функціональна форма setTodos
const addTodo = useCallback((text) => {
setTodos(prev => [...prev, text]);
}, []);
return (
<div>
<AddTodo onAdd={addTodo} />
<TodoItems todos={todos} />
</div>
);
}
// Без memo: рендериться при кожному оновленні батька
// З memo: рендериться тільки коли змінюється масив todos
const TodoItems = React.memo(({ todos }) => {
console.log('TodoItems рендериться');
return todos.map((todo, i) => <div key={i}>{todo}</div>);
});Без React.memo TodoItems рендериться кожного разу, коли батько оновлюється, навіть якщо todos не змінився. memo разом зі стабільним addTodo скорочує рендери до тих випадків, коли список справді змінюється.
Просунутий: пастка з об'єктом у пропсах
const UserCard = React.memo(({ user }) => {
console.log('UserCard рендериться'); // все одно логується
return <div>{user.name}</div>;
});
function Dashboard() {
const [tick, setTick] = useState(0);
// новий об'єкт при кожному рендері, shallow-порівняння завжди провалюється
const user = { name: 'Alice', id: 1 };
return (
<>
<button onClick={() => setTick(t => t + 1)}>Tick</button>
<UserCard user={user} />
</>
);
}
// Виправлення:
// const user = useMemo(() => ({ name: 'Alice', id: 1 }), []);React.memo порівнює пропси за shallow-рівністю. Два об'єктних літерали { name: 'Alice' } ніколи не рівні за посиланням, тому memo ніколи не блокує рендер. useMemo з порожнім масивом залежностей зберігає те саме посилання між рендерами. Я бачив цей баг у дашборді з 40+ картками, де кожен тік таймера перерендерував весь список через один інлайн-об'єкт стилів у пропсах.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.