React
Мемоизация: memo, useMemo, useCallback
Таблица на 5000 строк. Разработчик печатает в поле фильтра над ней, и каждый набранный символ подвешивает интерфейс на 200 миллисекунд. Профайлер показывает: при каждом нажатии React заново рендерит всю таблицу, хотя её данные не изменились. Виноват не React и не таблица, а одна функция-обработчик, которая пересоздаётся при каждом рендере и тянет за собой пересчёт всего поддерева. Эта история - про три инструмента, которые до выхода React Compiler были единственным способом её починить вручную: memo, useMemo и useCallback.
- Финансовые дашборды (Bloomberg-подобные терминалы): тысячи строк котировок, где лишний ре-рендер таблицы при каждом тике цены недопустим
- Редакторы вроде Figma и Notion: тяжёлые деревья компонентов, где мемоизация отсекает пересчёт неизменившихся панелей
- Списки в соцсетях: виртуализированные ленты оборачивают элементы в memo, чтобы скролл не дёргался
- Карты и графики (data-viz): дорогие вычисления координат кэшируются через useMemo, иначе каждый рендер пересчитывает геометрию
- До React Compiler v1.0 (2025) почти любой крупный проект на React содержал сотни ручных useMemo и useCallback
Предварительные знания
- Понимание, что React вызывает функцию-компонент заново при изменении state или props
- Reconciliation: как React сравнивает деревья и решает что обновить в DOM
- Разница между равенством по ссылке и равенством по значению в JavaScript
Откуда взялась ручная мемоизация в React
Идея пропускать работу, если входные данные не изменились, стара как программирование - это классическая мемоизация из теории вычислений. В React она появилась слоями. Сначала был shouldComponentUpdate в классовых компонентах и PureComponent с поверхностным сравнением props. Когда в React 16.6 (2018) добавили React.memo, тот же приём пришёл в функциональные компоненты. Хуки useMemo и useCallback прибыли вместе со всеми хуками в React 16.8 (февраль 2019). С тех пор и до 2025 года расстановка этих обёрток была обязательным навыком: разработчик вручную подсказывал React, где можно не пересчитывать. React Compiler v1.0 эту ручную работу почти отменил, но понимать механику по-прежнему необходимо.
Корень проблемы: референсное равенство
Чтобы понять, зачем нужны три инструмента мемоизации, надо начать с одного факта о JavaScript. Примитивы (числа, строки, булевы) сравниваются по значению: 5 равно 5. Объекты, массивы и функции сравниваются по ссылке: два объекта с одинаковым содержимым не равны, если это разные объекты в памяти. React при проверке props использует поверхностное сравнение - проходит по ключам и сравнивает значения через Object.is. Для примитива это работает интуитивно, для объекта - нет.
Теперь ключевой момент. Тело функции-компонента выполняется заново при каждом рендере. Каждый объектный литерал, каждый массив и каждая стрелочная функция внутри тела создаются заново - это новые ссылки. Если такое значение передаётся в дочерний компонент как prop, поверхностное сравнение всегда видит 'props изменились', даже когда содержимое то же самое.
В примере выше каждый клик по кнопке пересоздаёт style и handleClick. Child получает формально новые props при каждом рендере Parent, и любая попытка его мемоизировать окажется бесполезной, пока эти ссылки не стабилизированы. Это и есть та самая корневая причина, которую решают useMemo и useCallback.
Почему объект, созданный внутри тела компонента, считается новым prop при каждом рендере?
memo и useMemo: пропустить рендер и кэшировать значение
React.memo - это обёртка вокруг компонента. Она запоминает последние props и при следующем рендере родителя сравнивает новые props со старыми поверхностно. Если все они равны, React пропускает вызов этого компонента и переиспользует прошлый результат. Без memo компонент ре-рендерится всегда, когда ре-рендерится его родитель, независимо от props.
useMemo решает другую задачу - кэширует результат вычисления. Первый аргумент это функция, которая что-то считает, второй массив зависимостей. React вызывает функцию только когда одна из зависимостей изменилась, иначе возвращает запомненное значение. Два применения: дорогие вычисления (сортировка большого массива, тяжёлый парсинг) и стабилизация ссылки на объект или массив, который передаётся в memo-компонент.
- memo — Работает на уровне компонента. Решает, вызывать ли компонент вообще, сравнивая его props. Оборачивает определение компонента.
- useMemo — Работает на уровне значения внутри компонента. Решает, пересчитывать ли конкретное выражение, сравнивая массив зависимостей.
useMemo гарантирует кэш только как оптимизацию, а не как семантику. React оставляет за собой право иногда сбросить кэш (например, для освобождения памяти). Поэтому код не должен зависеть от того, что значение никогда не пересчитается - useMemo это подсказка, а не контракт хранения.
Компонент обёрнут в memo, но всё равно ре-рендерится при каждом рендере родителя. Какая причина наиболее вероятна?
useCallback и вопрос: когда это вообще нужно
useCallback - это частный случай useMemo для функций. Запись useCallback(fn, deps) эквивалентна useMemo(() => fn, deps). Он возвращает ту же самую ссылку на функцию между рендерами, пока зависимости не изменятся. Сам по себе useCallback ничего не ускоряет: его смысл проявляется только когда стабильная функция передаётся в memo-компонент или используется как зависимость другого хука.
Без useCallback здесь handleSelect был бы новой функцией при каждом рендере Parent, и memo вокруг Row стал бы бесполезен - все строки ре-рендерились бы при любом изменении selected. С useCallback ссылка стабильна, и React пропускает рендер строк, у которых не изменился item. Это связка: memo на ребёнке плюс стабильные props через useCallback и useMemo.
Главная ошибка - мемоизировать всё подряд. Каждый useMemo и useCallback сам по себе тратит память на хранение и время на сравнение зависимостей. Если ребёнок не обёрнут в memo, useCallback вокруг переданной ему функции не даёт ничего, кроме накладных расходов. Преждевременная мемоизация делает код шумным и иногда медленнее, чем без неё.
- Сначала измерить: открыть React DevTools Profiler и убедиться, что ре-рендеры действительно проблема
- Мемоизировать, когда дочерний компонент обёрнут в memo и получает объект или функцию как prop
- Мемоизировать значение, чьё вычисление измеримо дорого (большая сортировка, фильтрация, парсинг)
- Не мемоизировать дешёвые значения и компоненты, которые и так почти не рендерятся
- В новых проектах рассмотреть React Compiler, который снимает большую часть этой ручной работы
С React Compiler v1.0 (2025) большинство ручных memo, useMemo и useCallback становятся избыточными: компилятор анализирует код и расставляет эквивалентную мемоизацию сам. Но он работает только при соблюдении Rules of React, и понимание ручной механики остаётся базой для чтения чужого кода и отладки производительности.
Разработчик оборачивает каждую функцию-обработчик в проекте в useCallback, хотя дочерние компоненты не обёрнуты в memo. К чему это приведёт?
Связь с другими темами
Этот урок открывает блок про производительность. Дальше:
- React Compiler — Компилятор сам расставляет мемоизацию на этапе сборки, делая ручные обёртки почти ненужными
- Reconciliation — Фундамент под мемоизацией: именно процесс сравнения деревьев решает, дойдёт ли работа до компонента
- useTransition — Другой подход к отзывчивости: не пропустить работу, а понизить её приоритет
Итог
- memo оборачивает компонент и пропускает его ре-рендер, если props поверхностно равны предыдущим
- useMemo кэширует результат дорогого вычисления между рендерами, пока не изменятся зависимости
- useCallback кэширует саму функцию, сохраняя её ссылку стабильной между рендерами
- Корень большинства лишних ре-рендеров - референсное равенство: новый объект, массив или функция при каждом рендере ломает поверхностное сравнение
- Мемоизация не бесплатна: она тратит память и сравнения, поэтому оправдана при доказанной проблеме, а не превентивно
Связанные уроки
- rc-17-reconciliation — Мемоизация имеет смысл только когда понятно, как React решает что и когда ре-рендерить через reconciliation
- rc-20-react-compiler — React Compiler автоматизирует ровно ту работу, которую этот урок учит делать руками
- rc-13-useref — useRef тоже про сохранение значения между рендерами, но без участия в реактивности