Пошук уроків, статей та іншого контенту
Навчимося знаходити причини зайвих ререндерів через аналіз пропсів, стану, контексту та структури компонентів.
Ререндер — це повторний виклик функції компонента. Під час нього React створює нове дерево елементів і порівнює його з попереднім.
Ререндер не завжди означає зміну DOM. React може повторно виконати компонент, але не змінити жодного DOM-вузла.
Зайвим ререндером зазвичай називають повторний рендер компонента, коли:
його видимі дані не змінилися;
причина зміни не стосується цього компонента;
повторний рендер відбувається дуже часто;
він запускає дорогі обчислення або рендер великого піддерева.
Важливо: спочатку потрібно знайти причину ререндеру, а не одразу додавати memo, useMemo або useCallback.
Компонент може повторно рендеритися через чотири основні джерела:
змінився його власний стан;
змінилися пропси;
змінилося значення контексту;
повторно рендериться батьківський компонент.
Якщо викликати setter, React запланує ререндер компонента, який володіє цим станом.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}У цьому прикладі ререндер необхідний: компонент має показати нове значення count.
Але оновлення стану може бути зайвим, якщо значення фактично не змінилося:
setIsOpen(true);Якщо isOpen уже дорівнює true, React зазвичай не виконуватиме повторне оновлення через те саме примітивне значення. Для об’єктів і масивів важливе посилання, тому створення нового об’єкта може вважатися зміною навіть за однакових полів.
Батьківський компонент передає дані дочірньому через пропси. Якщо пропс змінився, дочірній компонент може повторно відрендеритися.
Для примітивів React порівнює значення:
<Profile name="Anna" />Для об’єктів, масивів і функцій порівнюється посилання:
<Profile user={{ name: "Anna" }} />Під час кожного рендера батьківського компонента створюється новий об’єкт. Його поля можуть бути такими самими, але посилання вже інше:
{ name: "Anna" } !== { name: "Anna" }Те саме стосується функцій:
<UserActions onSave={() => saveUser()} />Стрілкова функція створюється заново під час кожного рендера батьківського компонента.
За замовчуванням, коли компонент повторно рендериться, React також перевіряє його дочірні компоненти.
function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>
{count}
</button>
<Header />
</>
);
}Зміна count потрібна для кнопки, але не стосується Header. Проте без додаткової оптимізації Header також може бути викликаний повторно, оскільки його батьківський компонент ререндериться.
Це не обов’язково проблема. Простий компонент може повторно виконатися настільки швидко, що оптимізація лише ускладнить код.
Усі компоненти, які читають значення через useContext, реагують на зміну значення контексту.
const ThemeContext = createContext("light");
function Button() {
const theme = useContext(ThemeContext);
return <button className={theme}>Зберегти</button>;
}Якщо значення ThemeContext.Provider змінилося, Button повторно відрендериться.
Особливо часто зайві ререндери виникають, коли значення провайдера створюється як новий об’єкт:
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>Навіть якщо theme не змінився, новий об’єкт value створюється під час кожного рендера провайдера.
console.logНайпростіший спосіб — додати повідомлення на початок функції компонента:
function ProductCard({ product }) {
console.log("ProductCard render", product.id);
return <article>{product.name}</article>;
}Після цього потрібно виконати конкретну дію в інтерфейсі й перевірити:
які компоненти були викликані;
скільки разів вони викликалися;
чи змінювалися їхні дані;
чи ререндер відбувається після очікуваної дії.
Для зручнішого аналізу можна використати лічильник:
function ProductCard({ product }) {
const renderCount = useRef(0);
renderCount.current += 1;
console.log("ProductCard render:", renderCount.current);
return <article>{product.name}</article>;
}useRef зберігає значення між рендерами й не викликає новий ререндер після зміни.
StrictModeУ режимі розробки React може викликати деякі частини компонента додатково, щоб виявляти небезпечні побічні ефекти.
Тому подвійний виклик у консолі під час розробки не завжди означає проблему в логіці застосунку. Потрібно перевірити поведінку в production-збірці та використовувати профілювання React.
React DevTools містить вкладку Profiler, яка допомагає:
записати взаємодію користувача;
побачити, які компоненти рендерилися;
оцінити тривалість рендера;
визначити компоненти, що рендеряться найчастіше.
Під час аналізу важливо розрізняти:
компонент ререндерився;
компонент ререндерився довго;
ререндер компонента справді погіршує продуктивність.
Сам факт ререндеру не доводить наявність проблеми.
Щоб знайти причину ререндеру дочірнього компонента, перевірте кожен пропс:
чи змінилося його значення;
чи змінилося посилання;
хто створює це значення;
чи справді компонент використовує цей пропс.
Розглянемо приклад:
import { useState } from "react";
function UserCard({ user, onSelect }) {
console.log("UserCard render");
return (
<article>
<h2>{user.name}</h2>
<button onClick={onSelect}>Вибрати</button>
</article>
);
}
export default function App() {
const [count, setCount] = useState(0);
const user = {
id: 1,
name: "Anna",
};
return (
<main>
<button onClick={() => setCount((value) => value + 1)}>
Лічильник: {count}
</button>
<UserCard
user={user}
onSelect={() => console.log("Користувача вибрано")}
/>
</main>
);
}Після натискання кнопки лічильника App ререндериться. Разом із ним створюються:
новий об’єкт user;
нова функція onSelect.
Тому UserCard отримує нові пропси на кожному рендері.
Для діагностики можна тимчасово вивести посилання:
console.log({ user, onSelect });Або порівняти попередній і поточний пропс за допомогою useRef:
import { useEffect, useRef } from "react";
function UserCard(props) {
const previousProps = useRef(props);
useEffect(() => {
const changedProps = Object.keys(props).filter(
(key) => props[key] !== previousProps.current[key]
);
console.log("Змінені пропси:", changedProps);
previousProps.current = props;
});
return <article>{props.user.name}</article>;
}Такий код підходить для тимчасової діагностики. Його не обов’язково залишати в робочому компоненті.
У наступному прикладі є кілька джерел ререндерів:
локальний стан count;
новий об’єкт user;
нова функція onSelect;
значення контексту, яке створюється заново;
дочірній компонент, що ререндериться через батьківський.
import {
createContext,
memo,
useContext,
useRef,
useState,
} from "react";
import { createRoot } from "react-dom/client";
const SettingsContext = createContext(null);
function RenderCounter({ name }) {
const count = useRef(0);
count.current += 1;
console.log(`${name}: render ${count.current}`);
return null;
}
const UserCard = memo(function UserCard({ user, onSelect }) {
return (
<section>
<RenderCounter name="UserCard" />
<h2>{user.name}</h2>
<button onClick={onSelect}>Вибрати користувача</button>
</section>
);
});
function ThemeStatus() {
const settings = useContext(SettingsContext);
return (
<p>
<RenderCounter name="ThemeStatus" />
Тема: {settings.theme}
</p>
);
}
function App() {
const [count, setCount] = useState(0);
const [theme, setTheme] = useState("light");
const user = {
id: 1,
name: "Anna",
};
const handleSelect = () => {
console.log("Користувача вибрано");
};
const settings = {
theme,
setTheme,
};
return (
<SettingsContext.Provider value={settings}>
<main>
<RenderCounter name="App" />
<button onClick={() => setCount((value) => value + 1)}>
Лічильник: {count}
</button>
<button
onClick={() =>
setTheme((value) => (value === "light" ? "dark" : "light"))
}
>
Змінити тему
</button>
<UserCard user={user} onSelect={handleSelect} />
<ThemeStatus />
</main>
</SettingsContext.Provider>
);
}
createRoot(document.getElementById("root")).render(<App />);Якщо натискати кнопку лічильника, можна побачити таку поведінку:
App ререндериться через зміну власного стану.
user отримує нове посилання.
handleSelect отримує нове посилання.
UserCard ререндериться, навіть якщо його дані на екрані не змінилися.
settings отримує нове посилання.
ThemeStatus може ререндеритися через зміну значення контексту.
memo на UserCard у цьому прикладі не допомагає, оскільки його пропси щоразу нові. Це важливий діагностичний висновок: оптимізація порівнює пропси, але не робить об’єкти стабільними автоматично.
Чим вище в дереві компонентів розташований стан, тим більше піддерево потенційно залежить від його оновлення.
Порівняйте два підходи.
Стан на верхньому рівні:
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Header />
<Navigation />
<Modal isOpen={isOpen} />
</>
);
}Якщо isOpen потрібен лише модальному вікну, стан може бути ближче до цього компонента:
function ModalButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Відкрити</button>
{isOpen && <Modal />}
</>
);
}Під час пошуку зайвих ререндерів поставте питання:
який компонент володіє станом;
які компоненти справді використовують цей стан;
чи можна розмістити стан нижче в дереві;
чи не передається стан через багато рівнів без потреби.
Локалізація стану зменшує область компонентів, які залежать від його зміни.
Контекст зручний для даних, які потрібні багатьом компонентам. Але один контекст може містити незалежні значення:
const AppContext = createContext(null);
<AppContext.Provider
value={{
theme,
locale,
currentUser,
}}
>
{children}
</AppContext.Provider>;Якщо змінюється лише theme, споживачі, яким потрібен тільки locale, усе одно можуть отримати нове значення контексту.
Під час діагностики перевірте:
чи змінився сам value;
чи змінився потрібний компоненту фрагмент даних;
чи не об’єднано в одному контексті багато незалежних станів;
чи ререндериться компонент через контекст або через батьківський компонент.
Іноді причиною є не зміна даних, а створення нового об’єкта провайдера:
function SettingsProvider({ children }) {
const [theme, setTheme] = useState("light");
const value = {
theme,
setTheme,
};
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
}Якщо сам SettingsProvider ререндериться з іншої причини, value також стає новим об’єктом. Це може спричинити ререндер споживачів контексту.
Не аналізуйте застосунок загалом. Виберіть одну дію:
натискання кнопки;
введення символу;
відкриття меню;
зміна вкладки.
Запишіть, які компоненти, на вашу думку, мають оновитися.
Додайте console.log до підозрілих компонентів або використайте React DevTools Profiler.
Перевірте, чи дійсно компонент ререндериться, а не лише змінюється його DOM.
Для кожного ререндеру визначте:
який setter був викликаний;
який батьківський компонент ререндериться;
який пропс отримав нове значення;
який контекст читає компонент.
Особливу увагу приділіть:
об’єктам;
масивам;
callback-функціям;
значенням, створеним безпосередньо в JSX.
Перевірте не лише значення, а й посилання.
Запитайте себе:
чи справді цей компонент має бути дочірнім до компонента зі станом;
чи не передається стан через зайві рівні;
чи не ререндериться велике піддерево через одну маленьку зміну;
чи не можна розділити компонент на менші частини.
Після зміни структури або пропсів повторіть ту саму дію й порівняйте:
кількість ререндерів;
тривалість роботи;
поведінку інтерфейсу;
читабельність коду.
Невеликий компонент без дорогих обчислень може безпечно ререндеритися. Оптимізація потрібна тоді, коли є вимірюваний вплив на продуктивність або користувацький досвід.
memo без аналізу пропсівmemo пропускає ререндер лише тоді, коли пропси не змінилися за поверхневим порівнянням. Нові об’єкти та функції все одно вважатимуться змінами.
Повторний виклик компонента не означає, що React повністю перемалював сторінку. React порівнює результат і застосовує до DOM лише необхідні зміни.
Такий код створює новий об’єкт:
<Panel options={{ compact: true }} />Якщо стабільність options важлива для дочірнього компонента, це потрібно врахувати під час аналізу.
console.logЛоги показують факт виклику компонента, але не завжди пояснюють вплив на продуктивність. Для повної картини використовуйте Profiler і порівнюйте тривалість рендерів.
StrictModeПодвійні логи в режимі розробки можуть бути очікуваною поведінкою StrictMode. Не потрібно вимикати його лише через це.
Підняття стану спрощує обмін даними, але збільшує область потенційних ререндерів. Стан варто розміщувати на найвищому рівні, де він справді потрібен, а не вище.
Ререндер — це повторний виклик компонента, а не обов’язково зміна DOM.
Основні причини ререндерів — локальний стан, пропси, контекст і ререндер батьківського компонента.
Об’єкти, масиви та функції порівнюються за посиланням.
Нові об’єкти й callback-функції можуть робити пропси зміненими на кожному рендері.
Значення Provider контексту потрібно аналізувати так само, як і звичайні об’єкти-пропси.
Логи та React DevTools Profiler допомагають знайти компонент, що ререндериться, і причину цього.
Локалізація стану зменшує кількість компонентів, залежних від його змін.
Не кожен ререндер є проблемою: спочатку потрібно виміряти його вартість, а потім оптимізувати.