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 её обнуляют.
  • Мемоизация стоит памяти и сравнения на каждом рендере; кеш useMemo React вправе выбросить.
  • Сначала Profiler — потом оптимизация. В React 19 многое берёт на себя React Compiler.
Проверьте себя
1. Родитель оборачивает колбэк в useCallback и передаёт его дочернему компоненту, который НЕ обёрнут в React.memo. Что изменится?
AРебёнок перестанет перерисовываться при рендере родителя
BНичего полезного: ребёнок всё равно рендерится, а useCallback добавляет накладные расходы
CРебёнок будет рендериться только при изменении зависимостей useCallback
DReact автоматически применит memo к ребёнку
2. Компонент обёрнут в React.memo, но всё равно перерисовывается при каждом рендере родителя. Что вероятнее всего происходит?
AReact.memo работает только с классовыми компонентами
BЕму передают инлайновый объект, массив или стрелочную функцию — новая ссылка ломает поверхностное сравнение
CReact.memo сравнивает пропсы глубоко и находит различия внутри
DНужно дополнительно обернуть сам компонент в useMemo