useMemo, useCallback и React.memo
«Мы обернули колбэк в useCallback, а дочерний компонент всё равно перерисовывается на каждый чих. Почему?» — вопрос, на котором ломается половина кандидатов.
useMemo кеширует значение, useCallback — функцию, а React.memo — единственный из троицы, кто реально умеет пропустить рендер. Работают они только вместе; поодиночке первые два чаще всего бесполезны.
Вопрос: почему useCallback не помог?
import { useState, useCallback } from 'react';
function Dashboard() {
const [open, setOpen] = useState(false);
const [tick, setTick] = useState(0); // обновляется раз в секунду
const handleClick = useCallback(() => setOpen((o) => !o), []);
return <Panel onClick={handleClick} />; // Panel НЕ обёрнут в React.memo
}
function Panel({ onClick }) {
console.log('Panel рендерится');
return <button onClick={onClick}>Открыть</button>;
}
Ответ: Panel будет рендериться при каждом обновлении tick, и useCallback тут абсолютно ни при чём. Когда React перерисовывает родителя, он по умолчанию перерисовывает всё поддерево, не сравнивая пропсы. Стабильная ссылка на функцию есть — но её никто не сравнивает. useCallback без React.memo у получателя (или без использования этой функции в зависимостях другого хука) — чистый оверхед: лишний массив зависимостей, лишнее сравнение, лишний шум в коде.
Что каждый из них делает на самом деле
| Инструмент | Что кеширует | Пропускает ли рендер |
useMemo(fn, deps) | результат вызова fn() | нет |
useCallback(fn, deps) | саму функцию fn | нет |
React.memo(Comp) | ничего; сравнивает пропсы | да, если пропсы поверхностно равны |
Полезный факт для собеседования: useCallback(fn, deps) — это буквально сахар над useMemo(() => fn, deps). Оба хранят значение в ячейке хука и возвращают старое, пока зависимости совпадают по Object.is.
Как это работает под капотом
React.memo оборачивает компонент и перед рендером сравнивает старые пропсы с новыми поверхностно: те же ключи, и каждое значение равно старому по Object.is. Никакой магии — вот весь алгоритм:
function shallowEqual(prev, next) {
const keys = Object.keys(prev);
if (keys.length !== Object.keys(next).length) return false;
return keys.every((k) => Object.is(prev[k], next[k]));
}
const items = [1, 2, 3];
const onClick = () => {};
const prevProps = { items, onClick };
const nextPropsBad = { items, onClick: () => {} }; // новая функция на каждом рендере
const nextPropsGood = { items, onClick }; // ссылка стабильна (useCallback)
console.log('memo пропустит рендер (новая функция)?', shallowEqual(prevProps, nextPropsBad));
console.log('memo пропустит рендер (стабильная ссылка)?', shallowEqual(prevProps, nextPropsGood));
Результат:
memo пропустит рендер (новая функция)? false
memo пропустит рендер (стабильная ссылка)? true
Вот теперь связка ясна: React.memo пропустит рендер, только если все пропсы стабильны по ссылке. Один инлайновый колбэк или объект — и memo превращается в бесполезную обёртку, которая вдобавок каждый раз тратит время на сравнение.
Как ломают memo, сами того не замечая
const MemoList = React.memo(List);
// Все три пропса — новые объекты на каждом рендере родителя:
<MemoList
items={data.filter((d) => d.active)} // новый массив
style={{ padding: 8 }} // новый объект
onSelect={(id) => select(id)} // новая функция
/>;
// memo сравнит пропсы, увидит три различия и перерисует. Профит: минус производительность.
Сюда же — проброс children: JSX-элемент это объект, и он тоже создаётся заново при каждом рендере родителя.
Мемоизация не бесплатна
Кандидаты часто отвечают «useMemo лишним не бывает» — и это минус. Цена есть, и на собеседовании её ждут:
- Память: каждое закешированное значение и массив зависимостей висят в памяти всё время жизни компонента.
- Работа на каждом рендере: сравнение зависимостей происходит всегда, даже когда кеш попадает. Для
useMemo(() => a + b, [a, b])сравнение стоит дороже самого сложения. - Кеш не гарантирован: документация прямо говорит, что React может выбросить закешированное значение (например, ради освобождения памяти). Поэтому
useMemo— оптимизация, а не семантика. Никогда не полагайтесь на неё для корректности и не делайте внутри побочных эффектов. - Цена чтения: обвешанный хуками код труднее менять, а сломать мемоизацию одним инлайновым пропсом может любой коллега.
Когда мемоизация действительно оправдана: тяжёлое вычисление (сортировка/фильтрация тысяч элементов, парсинг), стабильная ссылка для зависимостей другого хука (useEffect с функцией в deps), стабильное value для Context.Provider и пропсы для React.memo-компонента, который сам по себе дорого рендерится (большая таблица, график).
Каверзное продолжение: второй аргумент React.memo
Если кандидат бодро отвечает про поверхностное сравнение, следующий вопрос обычно такой: «А можно ли заставить memo сравнивать пропсы иначе?» Можно: вторым аргументом React.memo принимает свою функцию сравнения areEqual(prevProps, nextProps), которая должна вернуть true, если рендер надо пропустить. Обратите внимание на инверсию: здесь true означает «пропсы равны, ничего не делаем» — противоположно логике устаревшего shouldComponentUpdate, где true значил «перерисовать». На этой инверсии спотыкаются регулярно.
И сразу ловушка: соблазн написать туда JSON.stringify(prev) === JSON.stringify(next) или глубокое сравнение. Так делать не стоит. Глубокое сравнение большого списка может стоить дороже самого рендера, оно ломается на функциях и undefined, зависит от порядка ключей и превращает предсказуемую оптимизацию в источник плавающих багов. Правильный ход — не усложнять сравнение, а стабилизировать пропсы на входе.
Хороший финал ответа: «Сначала измеряю Profiler'ом, потом мемоизирую то, что реально мешает. И держу в уме, что React Compiler в React 19 расставляет мемоизацию автоматически — ручные useMemo и useCallback постепенно становятся исключением, а не нормой».
Частые ошибки на собеседовании
- «useMemo нужен, чтобы компонент не перерисовывался». Нет: рендер пропускает
React.memo.useMemoлишь не пересчитывает значение внутри всё равно выполняющегося рендера. - «useCallback кеширует результат функции». Нет, он кеширует саму функцию. Результат — это
useMemo. - Обернуть в
useCallbackвсё подряд, не проверив, есть ли у ребёнкаmemoи попадает ли функция в чьи-то зависимости. - Обернуть компонент в
memoи тут же передать ему инлайновый объект или стрелку — memo не сработает ни разу. - Думать, что
memoсравнивает пропсы глубоко. Сравнение поверхностное, черезObject.is. - Мемоизировать вместо того, чтобы убрать причину. Часто лишние рендеры лечатся не хуками, а перестановкой состояния «вниз», ближе к тому, кто им пользуется, или прокидыванием тяжёлого поддерева через
children.
Итоги-шпаргалка
useMemo→ значение.useCallback→ функция.React.memo→ пропуск рендера.useCallback(fn, deps)≡useMemo(() => fn, deps).- Без
React.memoу ребёнка стабильная ссылка на колбэк никому не нужна. memoсравнивает пропсы поверхностно; один инлайновый объект, массив илиchildrenеё обнуляют.- Мемоизация стоит памяти и сравнения на каждом рендере; кеш
useMemoReact вправе выбросить. - Сначала Profiler — потом оптимизация. В React 19 многое берёт на себя React Compiler.