memo, useMemo и useCallback: что они делают
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Три инструмента с похожими именами делают три разные вещи: memo пропускает рендер компонента, useMemo хранит между рендерами значение, useCallback — функцию. Разочарование при первом знакомстве тоже общее: компонент обёрнут в memo, а перерисовывается на каждый клик. Почти всегда виноват не React, а объект или функция, которые мы сами создаём заново в каждом рендере.
memo — обёртка над компонентом. Она запоминает пропсы, с которыми компонент рендерился в прошлый раз, и при следующем рендере родителя сравнивает их с новыми. Совпали — React не вызывает функцию компонента вовсе и оставляет прошлый результат как есть. Не совпали — рендер идёт обычным порядком.
Из статьи «Триггер, рендер, коммит» известно: одна заявка на рендер тянет за собой рендер всего поддерева, даже тех компонентов, у которых ничего не менялось. memo ставит на этом пути заслон. Ниже состояние есть только у List, а Row получает одну и ту же строку — сколько строк «рендер Row» напечатают два клика?
Отрисовка Row одна — самая первая. На кликах React дошёл до Row, увидел прежний label и не стал вызывать функцию. Сам List при этом рендерится каждый раз: memo защищает не того, кто его вызвал, а того, кого им обернули.
Сравнение поверхностное: React проходит по ключам пропсов и сравнивает значения через Object.is — тем же способом, каким решает, изменилось ли состояние (о ссылках и сравнении шла речь в «Объекты и массивы в состоянии»). Вглубь объектов он не заглядывает и содержимое не обходит.
memo ничего не запрещает. Он утверждает: пропсы те же — значит, и результат будет тот же, вызывать незачем.
И сразу о границах. memo смотрит только на пропсы. Собственное состояние компонента и значение контекста, на который он подписан, перерисуют его независимо от того, обёрнут он или нет.
Тот же memo, но пропс — объект. Значения в нём от клика к клику не меняются: цвет всегда teal, текст всегда один. Предскажите, сколько раз напечатается «рендер Badge».
Три раза — по одному на каждый рендер Panel. Литерал { color: "teal" } внутри разметки — это выражение, и вычисляется оно на каждом рендере заново. Каждый раз получается новый объект: поля совпадают, ссылка другая.
Ровно это и видит memo: пропс style изменился, сравнение провалено, рендер идёт. То же с массивами (items={[]}), с результатом map и filter, с любой стрелочной функцией в разметке. Обёртка исправно работает — просто ей каждый раз подсовывают новые пропсы.
Строки, числа и булевы сравниваются по значению — с ними memo не подводит никогда. Ломается всё на объектах, массивах и функциях.
memo принимает вторым аргументом функцию сравнения. Соблазн понятен: сравним поля вручную и обойдёмся без useMemo. Цена — вы берёте на себя обязательство перечислить все пропсы, а забытый пропс превращается в компонент, который не обновляется никогда.
Здесь Badge перестал перерисовываться — и это правильно ровно до того дня, когда цвет начнёт зависеть от состояния. Обратите внимание и на знак: true означает «пропсы одинаковые, рендер не нужен» — противоположно привычному shouldComponentUpdate из старых классовых компонентов.
useMemo вызывает переданную функцию, запоминает результат и при следующих рендерах отдаёт запомненное — пока список зависимостей не изменится. Зависимости сравниваются так же поверхностно, элемент за элементом.
Один рендер Badge вместо трёх: style теперь один и тот же объект во всех рендерах, и сравнение в memo проходит. Пустой список зависимостей честен здесь потому, что внутри функции действительно нет ничего изменчивого — ни пропсов, ни состояния.
Вторая роль useMemo — не ссылка, а сам расчёт. Здесь сумма считается прямо в теле компонента. Сколько раз напечатается «считаем сумму» на двух кликах?
Трижды: тело компонента — обычная функция, и всё, что в нём написано, выполняется на каждом рендере. Для суммы пяти чисел это ничто, но на месте sumOf может оказаться сортировка тысячи строк или разбор текста.
Теперь расчёт один. Работает это только потому, что numbers стабилен: массив пришёл из useState и переживает рендеры. Объяви мы его литералом в теле компонента — зависимость менялась бы каждый раз, и useMemo пересчитывал бы всё заново, добавив к расчёту ещё и накладные расходы.
Функция внутри useMemo выполняется во время рендера — значит, она обязана быть чистой: никаких запросов, таймеров и правок DOM.
Стрелочная функция с фигурными скобками требует return — про это была речь ещё в «Стрелочных функциях». В useMemo такая опечатка особенно тиха: хук исправно работает и добросовестно запоминает undefined.
Компилятор указывает не на хук, а на восьмую строку — на разметку: «Тип "void" не может быть назначен для типа "ReactNode"». Тип возвращаемого значения useMemo выводится из вашей функции, а функция не возвращает ничего. Без типов ошибка дожила бы до браузера и обернулась пустым местом вместо суммы.
Функция — тоже объект, и с ней происходит ровно то же, что с литералом {}: каждый рендер рождает новую. Компонент обёрнут в memo, пропс всего один и на вид неизменный — но сколько будет строк «рендер Toolbar»?
Три. Лечится это useCallback — он запоминает саму функцию.
useCallback(fn, deps) — это в точности useMemo(() => fn, deps): отдельный хук существует ради читаемости, чтобы не писать функцию, возвращающую функцию. Никакого второго механизма под ним нет.
А теперь главная ловушка обоих хуков. Пустой список зависимостей выглядит безобидно и снимает лишние рендеры — но что он делает с замыканием? Три клика по кнопке: какое число окажется на ней в конце и что напечатает обработчик?
Обработчик все три раза видит ноль, и счётчик застревает на единице. Ничего специфически реактового здесь нет: это замыкание из части I. useCallback с пустым списком сохранил функцию первого рендера, а вместе с ней — count первого рендера. Каждый клик просит установить 0 + 1, и после первого раза значение уже такое.
Повторная строка «рендер, count = 1» в выводе — не опечатка: получив заявку на то же самое значение, React один раз вызвал компонент, убедился, что состояние не изменилось, и дальше по дереву не пошёл. На третий клик он не стал делать и этого.
Починка — не в добавлении count в зависимости (тогда функция снова станет новой на каждом рендере, и memo опять бесполезен), а в том, чтобы перестать читать состояние: функция-обновитель получает прошлое значение от самого React.
Отсюда правило: список зависимостей — не место для творчества. Он описывает, что функция читает снаружи, и врать в нём нельзя. Если хочется удалить оттуда значение — сначала уберите чтение этого значения из функции, как здесь. Проверку берёт на себя правило exhaustive-deps из eslint-plugin-react-hooks; ставить его стоит раньше, чем первый useCallback.
| Инструмент | Что оборачивает | Что сравнивает | Что экономит |
|---|---|---|---|
memo | компонент | все пропсы, поверхностно | вызов компонента и рендер его поддерева |
useMemo | вычисление | список зависимостей | повторный расчёт и новую ссылку |
useCallback | функцию | список зависимостей | новую ссылку на функцию |
Каждая строка этой таблицы — работа, которую React выполняет на каждом рендере: сравнить пропсы, сравнить зависимости, удержать в памяти прошлые значения. У компонента из пары узлов это сравнение вполне может стоить дороже, чем сам рендер, — и платить за него вы будете всегда: и когда мемоизация помогла, и когда нет.
Хуже другое: мемоизация заразна. memo сработает, только если стабильны все пропсы до единого. Один забытый объектный литерал обнуляет пользу от десятка useCallback выше по дереву — а обнаружить это можно только замером, потому что код выглядит правильным.
И ещё одно, о чём легко забыть: useMemo — оптимизация, а не гарантия. React вправе выбросить запомненное значение и посчитать заново. Поэтому на нём нельзя строить смысл программы: «создать ровно один экземпляр» — это useState(() => new Thing()) или useRef, но не useMemo.
Часть лишних рендеров снимается вообще без хуков — расстановкой компонентов. Приём знаком из «Что на самом деле тормозит»: элемент, созданный снаружи, приходит внутрь пропсом children и остаётся прежним объектом. Там он спасал список от флажка подсказки; здесь тот же ход заменяет memo. Сколько раз отрисуется Slow?
Один раз, хотя ни 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.
Оба пропса — свежие объекты в каждом рендере: user собирается литералом, onPick рождается стрелочной функцией. Стабилизируем оба.
Достаточно пропустить один из двух — и «рендер Card» снова напечатается трижды: memo сравнивает все пропсы, и одного изменившегося хватает.
Если user вообще не зависит от компонента, есть решение проще любых хуков: вынести объект наружу, в модуль. Тогда он создаётся один раз при загрузке файла, и мемоизировать нечего.
Корзина, застрявшая в прошлом
После трёх кликов в корзине должно оказаться три товара, а оказывается один. Найдите причину и почините — не удаляя useCallback и не добавляя items в зависимости.
useCallback с пустым списком зависимостей сохранил функцию первого рендера, а та замкнула пустой массив items. Каждый клик собирает новый массив из того самого пустого — и длина всегда равна единице.
Добавить items в зависимости можно, но тогда функция станет новой при каждом изменении корзины, и смысл useCallback испарится. Правильный ход — перестать читать состояние: прошлое значение приносит функция-обновитель.
Теперь пустой список зависимостей — правда, а не обещание: функция ничего не берёт из рендера. Тот же приём разбирался в «Функция-обновитель и очередь обновлений» — здесь он попутно чинит и мемоизацию.
Лишняя мемоизация
Запустите пример, затем удалите оба хука мемоизации и запустите снова: вывод не изменится ни на строку. Объясните, почему здесь они не дают ничего.
useMemo запоминает результат умножения — операции, которая дешевле, чем сравнение списка зависимостей вокруг неё. Чистый расход: память под прошлое значение плюс сравнение count на каждом рендере.
useCallback отдаёт стабильную функцию — но получает её обычный <button>, а не компонент под memo. Узлам DOM стабильность обработчика безразлична: React не переподписывает слушатели на каждый рендер, у него один общий слушатель на корень (об этом было в «Обработчики в JSX против addEventListener»).
Вывод тот же самый — три строки с теми же числами, — а работы на рендере стало меньше. Мемоизация окупается там, где за ней стоит либо тяжёлый расчёт, либо memo на дочернем компоненте.