Що таке React.memo і навіщо він потрібен
React.memo - це компонент вищого порядку (HOC), який обгортає функціональний компонент і пропускає його повторний рендер, якщо пропси не змінились.
Теорія
TL;DR
- React за замовчуванням ре-рендерить кожен дочірній компонент, коли батьківський рендериться заново, навіть якщо пропси дочірнього ідентичні. React.memo додає перевірку перед рендером.
- Порівняння поверхневе (shallow): примітиви порівнюються за значенням, об'єкти і функції - за посиланням.
- Використовуй, коли React Profiler показує, що компонент ре-рендериться з незмінними пропсами при кожному оновленні батька.
- Для функцій і об'єктів в пропсах завжди додавай
useCallbackабоuseMemo, інакше порівняння буде хибним кожного разу. - Другий аргумент дозволяє задати кастомну логіку порівняння.
Швидкий приклад
Без memo Button рендериться при кожній зміні стану батька. Інлайн-функція onClick - це новий об'єкт при кожному рендері, тому навіть обгортка в memo без useCallback нічого не дасть:
import { memo, useState, useCallback } from 'react';
const Button = memo(({ onClick }) => {
console.log('Button rendered');
return <button onClick={onClick}>Click</button>;
});
function App() {
const [count, setCount] = useState(0);
// Те саме посилання між рендерами
const handleClick = useCallback(() => console.log('clicked'), []);
return (
<>
<Button onClick={handleClick} />
<button onClick={() => setCount(c => c + 1)}>+1</button>
</>
);
}Button рендериться один раз. Натискання +1 оновлює count і ре-рендерить App, але handleClick зберігає своє посилання. Порівняння memo повертає рівність, і Button не рендериться знову.
Ключова різниця
Стандартна робота React: при рендері батька ре-рендеряться всі діти, навіть якщо їхній вивід не зміниться. React.memo вставляє поверхневу перевірку Object.is по кожному пропсу перед викликом функції компонента. Якщо всі пропси збіглись, React повертає закешований результат без виклику функції і без порівняння DOM. В деревах з великою кількістю листових вузлів, які рідко отримують нові дані, це помітно скорочує CPU-роботу.
Коли використовувати
- Елементи списків, які більшість часу не змінюються: обгортай кожен
<ListItem />в memo, щоб незмінені елементи пропускали рендер при оновленні одного. - Компоненти з повільним рендером: графіки, великі таблиці з фільтрацією, вкладені списки.
- Чисто відображувальні компоненти без локального стану.
- React Profiler показує, що компонент займає значну частину часу кадру при незмінених пропсах.
Не використовуй memo, коли:
- Компонент малий і рендериться менш ніж за 0.1 мс.
- Пропси змінюються при кожному рендері (інлайн-функція без
useCallbackзавжди дає нове посилання). - Немає конкретних даних профайлера. Memo додає накладні витрати навіть там, де нічого не економить.
Як React.memo працює всередині
React зберігає попередні пропси і останній ReactElement у fiber-вузлі компонента. При наступному рендері запускається shallowEqual: перебір Object.keys, Object.is для кожної пари значень. Якщо всі ключі збіглись, повертається закешований елемент і функція твого компонента взагалі не викликається. Накладні витрати на порівняння - приблизно 1-2 мікросекунди. Виграш є тільки якщо зекономлений рендер дорожчий за цю вартість.
В React 18 з concurrent mode memo також спрацьовує під час startTransition. Термінові оновлення (прямий ввід користувача) не відкладаються, і там memo запускається свіжо.
Кастомне порівняння
Коли поверхневого порівняння недостатньо, передай другий аргумент:
const UserCard = memo(({ user }) => {
return <div>{user.name}</div>;
}, (prevProps, nextProps) => {
// true = пропустити рендер, false = дозволити рендер
return prevProps.user.id === nextProps.user.id;
});Повернути true означає, що пропси вважаються рівними. Але обережно з функціями глибокого порівняння: повільна перевірка може коштувати більше, ніж той рендер, який намагаєшся уникнути.
Типові помилки
1. Інлайн-функції в пропсах
// Помилка: нове посилання на функцію при кожному рендері
<MemoChild onClick={() => alert(1)} />
// Виправлення
const handleAlert = useCallback(() => alert(1), []);
<MemoChild onClick={handleAlert} />Object.is(() => {}, () => {}) завжди false. Порівняння хибне щоразу, і memo нічого не дає.
2. Об'єктні літерали, створені під час рендеру
// Помилка: нове посилання при кожному рендері
const user = { name: 'Alice', details: { age: 30 } };
return <UserProfile user={user} />;
// Виправлення
const user = useMemo(() => ({ name: 'Alice', details: { age: 30 } }), []);Поверхневе порівняння перевіряє посилання на об'єкт, не його вміст. Новий літерал - нове посилання.
3. Мутація об'єктів пропсів на місці
Якщо передати { items: [] } і мутувати масив без заміни посилання, memo бачить те саме посилання і пропускає рендер. UI показує застарілі дані. Завжди роби іммутабельні оновлення.
4. Мемоізація дрібних компонентів без профілювання
Компонент, що рендериться за 0.05 мс, коштує більше в накладних витратах порівняння, ніж економить. Flamegraph у React DevTools точно покаже, де витрачається час. Перевір там перед тим, як додавати memo.
5. Нестабільний проп children
// Кожен рендер створює новий React-елемент
<MemoChild>
<span>label</span>
</MemoChild>children порівнюється за посиланням, як і будь-який інший проп. Новий JSX-вираз - новий об'єкт. Стабілізуй через useMemo або виноси статичних дітей у константу рівня модуля.
На практиці найпоширеніша проблема - забути useCallback на обробниках подій, що тихо нейтралізує memo в кожному компоненті, який їх отримує.
Де зустрічається
- React Window: row-компоненти мемоізовані для списків з 10 000+ елементів.
- Material-UI:
TableRowобгорнуто в memo, щоб уникнути ре-рендерів при прокрутці гриду. - TanStack Table: рендерери комірок мемоізовані, щоб пропускати роботу при сортуванні і фільтрації.
- TanStack Query: згенеровані хуки мемоізують вивід селекторів за тим же принципом, що
reselectдля Redux. - Next.js: memo позначає межі client-компонентів всередині server-дерев, щоб обмежити область ре-рендерів.
Follow-up питання
Q: Як працює поверхневе порівняння, якщо проп - це об'єкт?
A: React перебирає Object.keys і викликає Object.is для кожного значення. Примітиви порівнюються за значенням. Об'єкти, масиви і функції - за посиланням. Два різних об'єктних літерали з однаковим вмістом не рівні з точки зору Object.is.
Q: Коли React.memo погіршує продуктивність?
A: Коли компонент рендериться швидше, ніж виконується порівняння. Рендер за 0.05 мс проти 0.1-0.3 мс на перевірку пропсів нічого не економить. Профілюй через React DevTools перед тим, як додавати memo.
Q: Яка різниця між React.memo і useMemo?
A: React.memo обгортає компонент і кешує його вивід на основі рівності пропсів. useMemo - хук, що кешує будь-яке обчислене значення всередині компонента. Часто їх використовують разом: memo на межі компонента, useMemo для стабілізації об'єкта, що передається вниз як проп.
Q: Чи можна контролювати порівняння вручну?
A: Так. Другий аргумент - це (prevProps, nextProps) => boolean. Повернути true = пропустити рендер, false = дозволити. Функції глибокого порівняння тут працюють, але додають свою вартість, тому вимірюй перед використанням.
Q: Як React.memo поводиться з concurrent-режимом React 18?
A: Memo працює з startTransition. Під час низькопріоритетних переходів React може перервати і перезапустити рендери, і порівняння memo виконується при кожній спробі. Термінові оновлення не відкладаються, тому там memo запускається свіжо. Fiber-вузол відстежує тип компонента через тег MemoComponent.
Приклади
Базовий: мемоізований статусний бейдж
import { memo, useState } from 'react';
const StatusBadge = memo(({ status }) => {
console.log('StatusBadge rendered');
return <span className={`badge-${status}`}>{status}</span>;
});
function Dashboard() {
const [count, setCount] = useState(0);
return (
<div>
<StatusBadge status="active" />
<button onClick={() => setCount(c => c + 1)}>Кліків: {count}</button>
</div>
);
}StatusBadge рендериться один раз. Кожен наступний клік оновлює count і ре-рендерить Dashboard, але status залишається "active". Порівняння рядків проходить, бейдж пропускає рендер.
Середній: мемоізований елемент списку задач
import { memo, useState, useCallback } from 'react';
const TodoItem = memo(({ id, text, completed, onToggle }) => {
console.log(`TodoItem ${id} rendered`);
return (
<label>
<input
type="checkbox"
checked={completed}
onChange={() => onToggle(id)}
/>
{text}
</label>
);
});
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Купити продукти', completed: false },
{ id: 2, text: 'Написати тести', completed: false },
]);
const handleToggle = useCallback((id) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}, []);
return todos.map(todo => (
<TodoItem key={todo.id} {...todo} onToggle={handleToggle} />
));
}Перемикання першої задачі створює новий об'єкт тільки для неї. Пропси другої (id, text, completed, onToggle) залишаються ідентичними, рендер пропускається. В списку з 200 елементів одне оновлення пропускає 199 рендерів.
Просунутий: коли memo не спрацьовує через вкладені об'єкти
import { memo, useState, useMemo } from 'react';
const UserProfile = memo(({ user }) => {
console.log('UserProfile rendered');
return <div>{user.name} - {user.details.age}</div>;
});
function App() {
const [count, setCount] = useState(0);
// Помилка: новий об'єкт при кожному рендері
const user = { name: 'Alice', details: { age: 30 } };
return (
<>
<UserProfile user={user} />
<button onClick={() => setCount(c => c + 1)}>+1</button>
</>
);
}Кожен клік створює новий літерал user. Поверхневе порівняння бачить нове посилання і ре-рендерить компонент. Memo нічого не дає.
Виправлення: стабілізувати об'єкт.
const user = useMemo(() => ({ name: 'Alice', details: { age: 30 } }), []);Тепер user зберігає своє посилання між рендерами, і memo працює як очікується.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.