Пошук уроків, статей та іншого контенту
Розберете типові помилки оптимізації з useMemo та useCallback і навчитеся оцінювати їхню реальну користь.
Під час оновлення стану React повторно викликає компонент і будує нове дерево елементів. Сам факт повторного виклику функції компонента зазвичай не є проблемою. Проблеми виникають, коли рендер містить:
дорогі обчислення;
великі списки;
дочірні компоненти, які отримують нові посилання на функції або об’єкти;
складні піддерева, які фактично не змінилися.
useMemo і useCallback не «забороняють» React рендерити компонент. Вони допомагають зберігати значення або функцію між рендерами, щоб:
не повторювати дорогі обчислення;
зберігати стабільне посилання;
дати React.memo можливість пропустити рендер дочірнього компонента.
const value = useMemo(() => calculateValue(data), [data]);
const callback = useCallback(() => {
doSomething(value);
}, [value]);Кешування має сенс лише тоді, коли вартість кешу та перевірки залежностей менша за вартість повторного виконання або коли стабільне посилання справді використовується для оптимізації.
React порівнює залежності Hooks за допомогою порівняння, еквівалентного Object.is.
Для примітивів це зазвичай інтуїтивно:
Object.is("active", "active"); // true
Object.is(10, 10); // trueАле об’єкти, масиви та функції порівнюються за посиланням:
{} === {}; // false
[] === []; // false
const first = {};
const second = first;
first === second; // trueТому цей код не дає стабільної залежності:
function Component({ items }) {
const options = {
limit: 10,
};
const result = useMemo(() => {
return processItems(items, options);
}, [items, options]);
return <Result value={result} />;
}options створюється під час кожного рендера. Отже, залежність options щоразу вважається зміненою, і useMemo повторно виконує обчислення.
Краще створювати об’єкт усередині функції useMemo:
function Component({ items }) {
const result = useMemo(() => {
const options = {
limit: 10,
};
return processItems(items, options);
}, [items]);
return <Result value={result} />;
}У такому разі залежності описують реальні зовнішні дані обчислення.
useMemo: коли він справді кориснийuseMemo зберігає результат обчислення:
const filteredItems = useMemo(() => {
return items.filter((item) => item.name.includes(query));
}, [items, query]);Функція виконується під час першого рендера та тоді, коли змінюється items або query.
Типові випадки, коли useMemo може бути виправданим:
фільтрація або сортування великої колекції;
побудова складної структури даних;
дорогі перетворення даних;
передавання об’єкта в мемоізований дочірній компонент;
стабілізація значення, від якого залежать інші оптимізації.
Однак useMemo не робить обчислення безкоштовним. React все одно має:
зберігати попереднє значення;
зберігати залежності;
порівнювати залежності під час кожного рендера;
викликати функцію, якщо залежність змінилася.
Для простого виразу така оптимізація часто зайва:
const fullName = useMemo(() => `${firstName} ${lastName}`, [
firstName,
lastName,
]);Звичайний код простіший і, як правило, достатньо швидкий:
const fullName = `${firstName} ${lastName}`;useCallback: стабільне посилання на функціюuseCallback зберігає посилання на функцію:
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);Це не означає, що функція не виконується. Кешується саме об’єкт-функція, а не результат її роботи.
useCallback має практичний ефект переважно у двох випадках:
функція передається компоненту, обгорнутому в React.memo;
функція використовується як залежність іншого Hook.
Наприклад:
const Button = React.memo(function Button({ onClick }) {
return <button onClick={onClick}>Зберегти</button>;
});
function Form() {
const [value, setValue] = useState("");
const handleSave = useCallback(() => {
saveValue(value);
}, [value]);
return <Button onClick={handleSave} />;
}Якщо не використати useCallback, під час кожного рендера Form створюватиме нову функцію. React.memo побачить нове посилання в onClick і не зможе пропустити рендер Button.
Водночас якщо дочірній компонент не мемоізований, стабільність функції часто не дає користі:
function Button({ onClick }) {
return <button onClick={onClick}>Зберегти</button>;
}Такий компонент усе одно рендериться разом із батьківським компонентом.
У цьому прикладі:
useMemo кешує результат фільтрації;
useCallback зберігає функцію перемикання стану;
React.memo не дає незміненим рядкам списку рендеритися повторно;
функціональне оновлення стану дає змогу не додавати todos до залежностей callback.
import { memo, useCallback, useMemo, useState } from "react";
const initialTodos = [
{ id: 1, title: "Вивчити useMemo", completed: true },
{ id: 2, title: "Перевірити useCallback", completed: false },
{ id: 3, title: "Запустити Profiler", completed: false },
];
const TodoRow = memo(function TodoRow({ todo, onToggle }) {
console.log("Рендер рядка:", todo.id);
return (
<li>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
{todo.title}
</label>
</li>
);
});
function TodoList({ todos, onToggle }) {
return (
<ul>
{todos.map((todo) => (
<TodoRow key={todo.id} todo={todo} onToggle={onToggle} />
))}
</ul>
);
}
export default function App() {
const [todos, setTodos] = useState(initialTodos);
const [query, setQuery] = useState("");
const visibleTodos = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return todos;
}
return todos.filter((todo) =>
todo.title.toLowerCase().includes(normalizedQuery)
);
}, [todos, query]);
const handleToggle = useCallback((todoId) => {
setTodos((currentTodos) =>
currentTodos.map((todo) =>
todo.id === todoId
? { ...todo, completed: !todo.completed }
: todo
)
);
}, []);
return (
<main>
<h1>Список завдань</h1>
<label>
Пошук:
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Назва завдання"
/>
</label>
<p>Знайдено: {visibleTodos.length}</p>
<TodoList todos={visibleTodos} onToggle={handleToggle} />
</main>
);
}handleToggle має стабільне посилання, тому зміна query не змушує TodoRow отримувати нову функцію.
Оновлення списку використовує функціональну форму:
setTodos((currentTodos) => {
// ...
});Тому callback не читає todos із зовнішнього замикання і може мати порожній масив залежностей.
Під час зміни одного завдання map створює новий об’єкт лише для цього завдання:
todo.id === todoId
? { ...todo, completed: !todo.completed }
: todoДля інших елементів зберігаються попередні об’єкти. Завдяки цьому React.memo може порівняти пропси та пропустити рендер незмінених рядків.
visibleTodos також не створюється без потреби, якщо не змінилися todos або query.
Масив залежностей має містити всі значення, які читаються всередині функції Hook і можуть змінитися між рендерами.
Неправильний приклад:
function User({ userId }) {
const loadUser = useCallback(() => {
fetch(`/api/users/${userId}`);
}, []);
return <button onClick={loadUser}>Завантажити</button>;
}Функція запам’ятає значення userId з першого рендера. Якщо userId зміниться, callback може працювати зі старим значенням.
Правильний варіант:
function User({ userId }) {
const loadUser = useCallback(() => {
fetch(`/api/users/${userId}`);
}, [userId]);
return <button onClick={loadUser}>Завантажити</button>;
}Те саме стосується useMemo:
const userLabel = useMemo(() => {
return `${user.firstName} ${user.lastName}`;
}, [user]);Якщо всередині використовується user.firstName, але об’єкт user мутується без зміни посилання, React не побачить зміну. У React стан і пропси слід оновлювати незмінно, створюючи нові об’єкти.
Іноді callback залежить від стану лише тому, що читає його для наступного оновлення.
Необов’язково:
const addTodo = useCallback(() => {
setTodos([...todos, createTodo()]);
}, [todos]);Через todos функція створюється заново після кожної зміни списку.
Краще використати функціональний updater:
const addTodo = useCallback(() => {
setTodos((currentTodos) => [...currentTodos, createTodo()]);
}, []);Тепер callback не залежить від поточного значення todos, оскільки React передасть його в updater безпосередньо під час оновлення.
Це корисний спосіб зменшити залежності, але не причина штучно вилучати їх. Якщо callback безпосередньо використовує змінне значення, його потрібно додати до масиву залежностей.
useMemo не допомагаєconst isEmpty = useMemo(() => items.length === 0, [items]);Перевірка довжини масиву майже напевно дешевша за підтримку мемоізації. Звичайний варіант зрозуміліший:
const isEmpty = items.length === 0;const config = { sortBy: "name" };
const sortedItems = useMemo(() => {
return sortItems(items, config);
}, [items, config]);config створюється заново, тому кеш не використовується. Об’єкт потрібно або створити поза компонентом, якщо він не залежить від даних, або всередині callback useMemo.
const handleClick = useCallback(() => {
console.log("Натискання");
}, []);
return <button onClick={handleClick}>Натиснути</button>;Якщо цей callback не передається мемоізованому компоненту і не є залежністю іншого Hook, його стабільність не приносить помітної користі.
useMemo зупинить рендер батьківського компонентаfunction Parent({ items }) {
const result = useMemo(() => expensiveCalculation(items), [items]);
// Parent все одно викликається під час кожного свого рендера
return <Child result={result} />;
}useMemo кешує лише result. Він не зупиняє виконання Parent і не мемоізує автоматично Child.
useMemo як сховища стануuseMemo не призначений для зберігання даних, які потрібно зберігати як стан програми. Кеш може бути скинутий React, тому на нього не слід покладатися як на джерело істини.
Не слід використовувати useMemo для побічних ефектів або для логіки, яка повинна виконатися рівно один раз:
const value = useMemo(() => {
sendAnalyticsEvent();
return calculateValue();
}, []);Функція useMemo повинна виконувати чисте обчислення та повертати значення.
useMemo, useCallback і React.memoЦі механізми вирішують різні завдання:
useMemo кешує результат обчислення;
useCallback кешує посилання на функцію;
React.memo дозволяє компоненту пропустити рендер, якщо його пропси не змінилися.
Окремо вони не завжди дають оптимізацію.
Наприклад, React.memo не зможе пропустити рендер, якщо пропсом є новий об’єкт:
const Child = memo(function Child({ options }) {
return <div>{options.color}</div>;
});
function Parent() {
const options = { color: "blue" };
return <Child options={options} />;
}На кожному рендері options має нове посилання. Якщо об’єкт дійсно дорогий для створення або його стабільність потрібна Child, можна використати:
function Parent() {
const options = useMemo(() => ({ color: "blue" }), []);
return <Child options={options} />;
}Але для такого простого об’єкта це виправдано лише за наявності виміряної проблеми або реальної потреби в стабільних пропсах.
Оптимізацію варто перевіряти вимірюванням, а не кількістю використаних Hooks.
Послідовність аналізу:
Знайдіть повільну взаємодію: введення тексту, перемикання елемента, відкриття списку.
Перевірте компонент у React DevTools Profiler.
Визначте, що саме займає час:
обчислення;
рендер великої кількості дочірніх компонентів;
створення нових пропсів;
повторний рендер піддерева.
Додайте оптимізацію, яка відповідає причині.
Повторіть вимірювання.
Переконайтеся, що код залишився коректним і залежності повні.
Для оцінювання важливо перевіряти сценарій, який справді повільний. Мемоізація компонента, що рендериться один раз і займає мікросекунди, навряд чи дасть відчутний результат.
Логування через console.log корисне для перевірки факту рендера, але не для точного вимірювання продуктивності. Для цього використовуйте профілювання та порівнюйте однакові дії до і після зміни.
У режимі розробки деякі перевірки React можуть призводити до додаткових викликів функцій. Це не означає автоматично, що в production буде така сама кількість виконань, тому остаточні висновки потрібно робити з урахуванням режиму запуску.
useMemo і useCallback всюдиМемоізація ускладнює код і додає залежності. Вона повинна бути відповіддю на конкретну проблему, а не правилом стилю.
Видалення залежності може приховати помилку зі старим замиканням. Якщо значення використовується всередині callback або обчислення, воно зазвичай має бути в залежностях.
Створення об’єкта або функції під час кожного рендера зводить нанівець очікувану стабільність. Потрібно перевірити, як формуються всі значення, які передаються мемоізованим компонентам.
React.memouseCallback сам по собі не пропускає рендер дочірнього компонента. Якщо дочірній компонент не порівнює пропси, стабільне посилання на callback може не мати практичного ефекту.
Якщо об’єкт або масив змінюється на місці, його посилання залишається тим самим. React і мемоізація можуть не побачити зміну.
Неправильно:
todo.completed = true;
setTodos(todos);Правильно:
setTodos((currentTodos) =>
currentTodos.map((todo) =>
todo.id === id ? { ...todo, completed: true } : todo
)
);Якщо компонент працює неправильно через неправильні залежності, useMemo або useCallback не є виправленням. Спочатку потрібно забезпечити коректність, а потім виміряти продуктивність.
useMemo кешує результат обчислення, а не сам компонент.
useCallback кешує посилання на функцію, а не результат її виконання.
Найчастіше ці Hooks корисні разом із React.memo або коли значення є залежністю іншого Hook.
Залежності порівнюються за посиланням, тому нові об’єкти, масиви та функції можуть руйнувати кеш.
Функціональний updater допомагає не створювати зайві залежності в callback.
Не слід мемоізувати дешеві обчислення без виміряної причини.
useMemo не є сховищем стану і не повинен виконувати побічні ефекти.
Реальну користь оптимізації потрібно перевіряти профілюванням до та після зміни.