memo, useMemo и useCallback: что они делают

скорость≈ 12 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Три инструмента с похожими именами делают три разные вещи: memo пропускает рендер компонента, useMemo хранит между рендерами значение, useCallback — функцию. Разочарование при первом знакомстве тоже общее: компонент обёрнут в memo, а перерисовывается на каждый клик. Почти всегда виноват не React, а объект или функция, которые мы сами создаём заново в каждом рендере.

Разделы темы:·

memo — обёртка над компонентом. Она запоминает пропсы, с которыми компонент рендерился в прошлый раз, и при следующем рендере родителя сравнивает их с новыми. Совпали — React не вызывает функцию компонента вовсе и оставляет прошлый результат как есть. Не совпали — рендер идёт обычным порядком.

Из статьи «Триггер, рендер, коммит» известно: одна заявка на рендер тянет за собой рендер всего поддерева, даже тех компонентов, у которых ничего не менялось. memo ставит на этом пути заслон. Ниже состояние есть только у List, а Row получает одну и ту же строку — сколько строк «рендер Row» напечатают два клика?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отрисовка Row одна — самая первая. На кликах React дошёл до Row, увидел прежний label и не стал вызывать функцию. Сам List при этом рендерится каждый раз: memo защищает не того, кто его вызвал, а того, кого им обернули.

Сравнение поверхностное: React проходит по ключам пропсов и сравнивает значения через Object.is — тем же способом, каким решает, изменилось ли состояние (о ссылках и сравнении шла речь в «Объекты и массивы в состоянии»). Вглубь объектов он не заглядывает и содержимое не обходит.

memo ничего не запрещает. Он утверждает: пропсы те же — значит, и результат будет тот же, вызывать незачем.

И сразу о границах. memo смотрит только на пропсы. Собственное состояние компонента и значение контекста, на который он подписан, перерисуют его независимо от того, обёрнут он или нет.

Тот же memo, но пропс — объект. Значения в нём от клика к клику не меняются: цвет всегда teal, текст всегда один. Предскажите, сколько раз напечатается «рендер Badge».

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три раза — по одному на каждый рендер Panel. Литерал { color: "teal" } внутри разметки — это выражение, и вычисляется оно на каждом рендере заново. Каждый раз получается новый объект: поля совпадают, ссылка другая.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ровно это и видит memo: пропс style изменился, сравнение провалено, рендер идёт. То же с массивами (items={[]}), с результатом map и filter, с любой стрелочной функцией в разметке. Обёртка исправно работает — просто ей каждый раз подсовывают новые пропсы.

Строки, числа и булевы сравниваются по значению — с ними memo не подводит никогда. Ломается всё на объектах, массивах и функциях.

memo принимает вторым аргументом функцию сравнения. Соблазн понятен: сравним поля вручную и обойдёмся без useMemo. Цена — вы берёте на себя обязательство перечислить все пропсы, а забытый пропс превращается в компонент, который не обновляется никогда.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Здесь Badge перестал перерисовываться — и это правильно ровно до того дня, когда цвет начнёт зависеть от состояния. Обратите внимание и на знак: true означает «пропсы одинаковые, рендер не нужен» — противоположно привычному shouldComponentUpdate из старых классовых компонентов.

useMemo вызывает переданную функцию, запоминает результат и при следующих рендерах отдаёт запомненное — пока список зависимостей не изменится. Зависимости сравниваются так же поверхностно, элемент за элементом.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Один рендер Badge вместо трёх: style теперь один и тот же объект во всех рендерах, и сравнение в memo проходит. Пустой список зависимостей честен здесь потому, что внутри функции действительно нет ничего изменчивого — ни пропсов, ни состояния.

Вторая роль useMemo — не ссылка, а сам расчёт. Здесь сумма считается прямо в теле компонента. Сколько раз напечатается «считаем сумму» на двух кликах?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Трижды: тело компонента — обычная функция, и всё, что в нём написано, выполняется на каждом рендере. Для суммы пяти чисел это ничто, но на месте sumOf может оказаться сортировка тысячи строк или разбор текста.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Теперь расчёт один. Работает это только потому, что numbers стабилен: массив пришёл из useState и переживает рендеры. Объяви мы его литералом в теле компонента — зависимость менялась бы каждый раз, и useMemo пересчитывал бы всё заново, добавив к расчёту ещё и накладные расходы.

Функция внутри useMemo выполняется во время рендера — значит, она обязана быть чистой: никаких запросов, таймеров и правок DOM.

Стрелочная функция с фигурными скобками требует return — про это была речь ещё в «Стрелочных функциях». В useMemo такая опечатка особенно тиха: хук исправно работает и добросовестно запоминает undefined.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор указывает не на хук, а на восьмую строку — на разметку: «Тип "void" не может быть назначен для типа "ReactNode"». Тип возвращаемого значения useMemo выводится из вашей функции, а функция не возвращает ничего. Без типов ошибка дожила бы до браузера и обернулась пустым местом вместо суммы.

Функция — тоже объект, и с ней происходит ровно то же, что с литералом {}: каждый рендер рождает новую. Компонент обёрнут в memo, пропс всего один и на вид неизменный — но сколько будет строк «рендер Toolbar»?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три. Лечится это useCallback — он запоминает саму функцию.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

useCallback(fn, deps) — это в точности useMemo(() => fn, deps): отдельный хук существует ради читаемости, чтобы не писать функцию, возвращающую функцию. Никакого второго механизма под ним нет.

А теперь главная ловушка обоих хуков. Пустой список зависимостей выглядит безобидно и снимает лишние рендеры — но что он делает с замыканием? Три клика по кнопке: какое число окажется на ней в конце и что напечатает обработчик?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обработчик все три раза видит ноль, и счётчик застревает на единице. Ничего специфически реактового здесь нет: это замыкание из части I. useCallback с пустым списком сохранил функцию первого рендера, а вместе с ней — count первого рендера. Каждый клик просит установить 0 + 1, и после первого раза значение уже такое.

Повторная строка «рендер, count = 1» в выводе — не опечатка: получив заявку на то же самое значение, React один раз вызвал компонент, убедился, что состояние не изменилось, и дальше по дереву не пошёл. На третий клик он не стал делать и этого.

Починка — не в добавлении count в зависимости (тогда функция снова станет новой на каждом рендере, и memo опять бесполезен), а в том, чтобы перестать читать состояние: функция-обновитель получает прошлое значение от самого React.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отсюда правило: список зависимостей — не место для творчества. Он описывает, что функция читает снаружи, и врать в нём нельзя. Если хочется удалить оттуда значение — сначала уберите чтение этого значения из функции, как здесь. Проверку берёт на себя правило exhaustive-deps из eslint-plugin-react-hooks; ставить его стоит раньше, чем первый useCallback.

ИнструментЧто оборачиваетЧто сравниваетЧто экономит
memoкомпонентвсе пропсы, поверхностновызов компонента и рендер его поддерева
useMemoвычислениесписок зависимостейповторный расчёт и новую ссылку
useCallbackфункциюсписок зависимостейновую ссылку на функцию

Каждая строка этой таблицы — работа, которую React выполняет на каждом рендере: сравнить пропсы, сравнить зависимости, удержать в памяти прошлые значения. У компонента из пары узлов это сравнение вполне может стоить дороже, чем сам рендер, — и платить за него вы будете всегда: и когда мемоизация помогла, и когда нет.

Хуже другое: мемоизация заразна. memo сработает, только если стабильны все пропсы до единого. Один забытый объектный литерал обнуляет пользу от десятка useCallback выше по дереву — а обнаружить это можно только замером, потому что код выглядит правильным.

И ещё одно, о чём легко забыть: useMemo — оптимизация, а не гарантия. React вправе выбросить запомненное значение и посчитать заново. Поэтому на нём нельзя строить смысл программы: «создать ровно один экземпляр» — это useState(() => new Thing()) или useRef, но не useMemo.

Часть лишних рендеров снимается вообще без хуков — расстановкой компонентов. Приём знаком из «Что на самом деле тормозит»: элемент, созданный снаружи, приходит внутрь пропсом children и остаётся прежним объектом. Там он спасал список от флажка подсказки; здесь тот же ход заменяет memo. Сколько раз отрисуется Slow?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Один раз, хотя ни memo, ни useMemo в коде нет. App на клик не перерисовывается, элемент <Slow /> создан там и не пересоздаётся, а Layout получает в children ту же ссылку — React сравнивает её и не идёт вглубь. Тот же приём, только не в хуках, а в форме компонентов.

Сначала измерьте, потом мемоизируйте. Мемоизация «на всякий случай» — это гарантированные расходы ради предполагаемой выгоды.

Всё это — ручная работа, которую легко сделать неправильно: забыть зависимость, обернуть не то, оставить memo на компоненте, куда прилетает свежий объект. Следующая статья про React Compiler показывает, как та же мемоизация появляется в сборке сама и уходит из вашего кода.

  • memo сравнивает пропсы поверхностно, через Object.is, и пропускает рендер компонента, если все они те же.
  • Объект, массив и функция, созданные в теле компонента, каждый рендер новые — из-за них memo чаще всего и не срабатывает.
  • useMemo хранит между рендерами значение, useCallback — функцию, и второе есть в точности первое: useCallback(fn, deps) — это useMemo(() => fn, deps).
  • Пустой список зависимостей замораживает вместе с функцией и все прочитанные ею значения; лечится функцией-обновителем, а не враньём в зависимостях.
  • Мемоизация не бесплатна: сравнение и память тратятся на каждом рендере, а один нестабильный пропс обнуляет всю цепочку.
  • Часть лишних рендеров снимается композицией: элемент, переданный через children, не пересоздаётся родителем и без всяких хуков.

1/4Компонент Badge обёрнут в memo и получает пропс style={{ color: "teal" }} — литерал прямо в разметке родителя. Родитель перерисовался из-за своего состояния. Что сделает Badge?

2/4Обработчик кнопки объявлен как const inc = React.useCallback(() => setCount(count + 1), []). Читатель нажимает кнопку три раза. Что покажет счётчик?

3/4Чем useCallback(fn, deps) отличается от useMemo(() => fn, deps)?

4/4Компонент обёрнут в memo, все его пропсы стабильны, но внутри он держит собственное состояние через useState. Вызван сеттер. Что произойдёт?

Починить memo

Card обёрнут в memo, но перерисовывается на каждый клик по соседней кнопке. Сделайте так, чтобы «рендер Card» напечаталось ровно один раз. Сам Card трогать нельзя — правьте только Page.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Оба пропса — свежие объекты в каждом рендере: user собирается литералом, onPick рождается стрелочной функцией. Стабилизируем оба.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Достаточно пропустить один из двух — и «рендер Card» снова напечатается трижды: memo сравнивает все пропсы, и одного изменившегося хватает.

Если user вообще не зависит от компонента, есть решение проще любых хуков: вынести объект наружу, в модуль. Тогда он создаётся один раз при загрузке файла, и мемоизировать нечего.

Корзина, застрявшая в прошлом

После трёх кликов в корзине должно оказаться три товара, а оказывается один. Найдите причину и почините — не удаляя useCallback и не добавляя items в зависимости.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

useCallback с пустым списком зависимостей сохранил функцию первого рендера, а та замкнула пустой массив items. Каждый клик собирает новый массив из того самого пустого — и длина всегда равна единице.

Добавить items в зависимости можно, но тогда функция станет новой при каждом изменении корзины, и смысл useCallback испарится. Правильный ход — перестать читать состояние: прошлое значение приносит функция-обновитель.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь пустой список зависимостей — правда, а не обещание: функция ничего не берёт из рендера. Тот же приём разбирался в «Функция-обновитель и очередь обновлений» — здесь он попутно чинит и мемоизацию.

Лишняя мемоизация

Запустите пример, затем удалите оба хука мемоизации и запустите снова: вывод не изменится ни на строку. Объясните, почему здесь они не дают ничего.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

useMemo запоминает результат умножения — операции, которая дешевле, чем сравнение списка зависимостей вокруг неё. Чистый расход: память под прошлое значение плюс сравнение count на каждом рендере.

useCallback отдаёт стабильную функцию — но получает её обычный <button>, а не компонент под memo. Узлам DOM стабильность обработчика безразлична: React не переподписывает слушатели на каждый рендер, у него один общий слушатель на корень (об этом было в «Обработчики в JSX против addEventListener»).

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод тот же самый — три строки с теми же числами, — а работы на рендере стало меньше. Мемоизация окупается там, где за ней стоит либо тяжёлый расчёт, либо memo на дочернем компоненте.