Мемоизация коллбэков в списках react-приложения

Оглавление
Типичные ошибки
Ошибка 1: Анонимные обертки внутри компонента
Ошибка 2: Мемоизация фабрики функций с аргументами
Ошибка 3: Отсутствие мемоизации дочернего компонента
Возможные решения
Предварительное создание обработчиков для каждого элемента
Использование useRef для хранения хендлеров
Использование lodash.memoize внутри useCallback
Передача элемента и делегирование обработки
Использование react compiler (beta)
Вывод
В процессе работы над различными проектами я неоднократно сталкивался с проблемами, когда неэффективная мемоизация коллбэков приводила к избыточным рендерам и ухудшению производительности приложения. Особенно часто такие ситуации возникают при передаче параметров в функции-обработчики и использовании мемоизации в дочерних компонентах. В этой статье я поделюсь реальными вариантами решения этих проблем на основе накопленного опыта. Стоит отметить, что я не претендую на новизну — решение проверены временем и подойдут не во всех случаях, но многие подходы уже не раз доказали свою эффективность.
Типичные ошибки
Давайте рассмотрим несколько типичных ошибок более подробно:
Ошибка 1: Анонимные обертки внутри компонента
Иногда разработчики оборачивают функцию-обработчик в анонимную функцию прямо при передаче в пропсы. Да, используем useCallback
для исходного обработчика, но при этом создается новая функция-обертка на каждом рендере. Такая ситуация выглядит так:
export const App = () => {
const [cards, setCards] = useState([]);
const handleClick = useCallback((card) => setCards(card), [setCards]);
return (
<>
{CARDS.map((card) => (
// Здесь каждый раз создаётся новая функция, даже если handleClick мемоизирован
handleClick(card)} />
))}
);
}
Проблема: анонимная функция () => handleClick(card)
создается заново при каждом рендере, что приводит к тому, что даже при использовании memo
для компонента Card
его пропсы всегда будут ссылочно отличаться. Это сбивает механизм мемоизации и вынуждает React ререндерить компонент.
Ошибка 2: Мемоизация фабрики функций с аргументами
Иногда попытки мемоизировать возвращаемую функцию приводят к логической ошибке. Суть подхода в том, что мы используем useCallback
для создания фабрики функций, где каждый вызов фабрики возвращает новую функцию:
export const App = () => {
const [cards, setCards] = useState([]);
const handleClick = useCallback((card) => () => setCards(card), [setCards]);
return (
<>
{CARDS.map((card) => (
// При каждом рендере вызов handleClick(card) возвращает новую функцию
))}
);
}
Проблема: даже если мы мемоизируем саму функцию handleClick
, вызов handleClick(card)
генерирует новую функцию на каждом рендере. Это снова приводит к тому, что дочерний компонент получает новую ссылку на функцию и ререндеривается, несмотря на применение memo
.
Ошибка 3: Отсутствие мемоизации дочернего компонента
Бывает, что разработчики полагаются на встроенные оптимизации React, ожидая, что компоненты сами «умомудрятся» избежать лишних ререндеров. Однако, если дочерний компонент не мемоизирован (например, с помощью React.memo
), то даже минимальные изменения в родительском компоненте вызовут его повторный рендер.
Проблема: без явной мемоизации (React.memo
) структура пропсов сравнивается «на лету», и в случае, когда передаются функции или объекты, создаются новые экземпляры, что приводит к нежелательным ререндерам.
Возможные решения
Далее я представлю различные варианты решения этих проблем на основе практики. Будут разобраны подходы по реорганизации кода таким образом, чтобы избежать создания анонимных функций при каждом рендере и минимизировать генерацию новых экземпляров функций с аргументами. Кроме того, я продемонстрирую, как правильно применять React.memo
для дочерних компонентов. Подчеркиваю, что это варианты из практики — они проверены, но могут не покрывать абсолютно все случаи. Возможно, в процессе я предложу лишь часть возможных решений, прошу отнестись с пониманием.
Давайте перейдём к конкретным примерам и разберем, как можно переписать код для достижения лучшей производительности и читаемости.
1. Предварительное создание обработчиков для каждого элемента
Вместо того чтобы создавать функцию-обработчик прямо в JSX (например, через анонимную стрелочную функцию), можно заранее сгенерировать набор обработчиков для каждого элемента списка. Это означает, что обработчики будут создаваться один раз (например, при инициализации или при изменении списка), и использоваться уже при каждом рендере.
Пример реализации
Предположим, у нас есть массив элементов CARDS
, каждый из которых имеет уникальное свойство id
. Тогда можно подготовить объект с обработчиками:
export const App = () => {
const [cards, setCards] = useState([]);
const handlers = useMemo(
() =>
CARDS.reduce((acc, card) => {
acc[card.id] = () => setCards(card);
return acc;
}, {}),
[CARDS, setCards]
);
return (
<>
{CARDS.map((card) => (
))}
);
}
Плюсы:
Коллбэки создаются один раз и сохраняются в объекте.
При передаче в дочерние компоненты идентичность функций сохраняется, что предотвращает лишние рендеры.
Минусы:
При большом количестве элементов список «объект-хэндлеров» может стать слишком большим.
Если список карточек динамический (например, меняется порядок, удаляются или добавляются карточки), то приходится следить за консистентностью обновлений в мемоизированном объекте.
Такой подход может снизить читаемость кода, особенно если логика создания обработчиков усложняется.
2. Использование useRef для хранения хендлеров
Можно использовать хук useRef
для хранения ранее созданных обработчиков. Таким образом, если компонент перерендеривается, обработчики уже будут доступны из ref, и не придётся создавать их заново.
Пример реализации
export const App = () => {
const handlersRef = useRef({});
const [cards, setCards] = useState([]);
useEffect(() => {
CARDS.forEach((card) => {
if (!handlersRef.current[card.id]) {
handlersRef.current[card.id] = () => setCards(card.id);
}
});
}, [CARDS, setCards]);
return (
<>
{CARDS.map((card) => (
))}
);
}
Плюсы:
Аналогично первому методу, функции создаются один раз и хранятся вне цикла рендеринга.
Обращение к рефу не вызывает повторный рендер компонента.
Минусы:
Логика обновления хендлеров смещается в
useEffect
, что может усложнить понимание и отладку.Могут возникнуть проблемы с консистентностью, если список изменяется динамически (например, если карточка появляется или исчезает) — нужно внимательно следить за заполнением рефа.
Подобный мутирующий паттерн (
handlersRef.current
) менее декларативен и может привести к ошибкам, если не следить за зависимостями.
3. Использование lodash.memoize внутри useCallback
Можно использовать библиотеку lodash
(а именно функцию memoize
) для кеширования обработчиков по аргументам. В сочетании с useCallback
это позволяет создать обертку, которая возвращает одну и ту же функцию при передаче одного и того же параметра.
Пример реализации
export const App = () => {
const [cards, setCards] = useState([]);
const handleClick = useCallback(
memoize((card) => () => setCards(card)),
[setCards]
);
return (
<>
{CARDS.map((card) => (
))}
);
}
Плюсы:
Позволяет кэшировать создание обработчиков, возвращая уже созданную функцию для конкретного значения параметра.
Обеспечивает стабильность идентичности функций при повторном рендеринге.
Минусы:
Использование сторонней библиотеки (
lodash
) для решения достаточно специфичной задачи может быть избыточным.Есть риск утечек памяти, если набор передаваемых значений (например, карточек) постоянно растёт, а механизм мемоизации не «очищается».
Такой подход менее распространён, и его поддержку и понимание могут затруднить для других разработчиков, не знакомых с
lodash.memoize
.
4. Передача элемента и делегирование обработки
Иногда можно отказаться от передачи параметров вообще и передавать только элемент, а обработку параметра — выполнять внутри узла списка.
Пример реализации
const Card = memo(({ data, onClick }) => (
{data.name}
));
export const App = () => {
const [cards, setCards] = useState([]);
return (
<>
{CARDS.map((card) => (
// напоминаю, что дополнительно мемоизировать setCards не нужно
// react это сделает за нас
))}
);
}
Плюсы:
Самый простой и понятный подход.
Отделение логики отображения карточки от логики обработки нажатия: компонент
Card
получает данные и делегирует обработку через свой внутренний коллбэк.Если дочерний компонент обернут в
React.memo
, его повторный рендеринг происходит только при изменении пропсов.Нет необходимости создавать много отдельных функций в родительском компоненте — передается функция-обработчик, которая может использоваться на уровне дочернего компонента.
Минусы:
Такой подход требует, чтобы внутренняя логика компонента
Card
корректно управлялась зависимостями (например, при использованииuseCallback
внутриCard
, зависящем от пробрасываемого объекта карточки).В случае, если внутри
Card
используется передача всего объекта, а не идентификатора, может возникнуть лишний ререндер, если объект не мемоизирован.
5. Использование react compiler (beta)
Это новый способ отказаться от мемоизации «руками» в React. Достаточно установить пакет babel-plugin-react-compiler@beta
для React 19 и дополнительно пакет react-compiler-runtime
для React 17‑18. Другие версии на момент написания статьи не поддерживаются. Таким образом, мы снимаем ответственность за мемоизацию с разработчика. Подробности работы и эффективность данного подхода мы рассмотрим в другой статье.
Вывод
Подводя итоги, можно сказать, что делегирование обработки (подход №4) представляет собой наиболее элегантное и понятное решение для оптимизации коллбэков в списках React-приложений. Оно позволяет избежать ненужного «шумного» кода, который характерен для подходов №1 и №2, и не вводит дополнительных зависимостей, как в случае с lodash.memoize
(подход №3).
Конечно, ничего не бывает универсальным, и выбор оптимального решения всегда зависит от особенностей проекта, но для большинства сценариев делегирование выглядит наиболее предпочтительным. Обращаясь к лучшим практикам, стоит выбирать простые и поддерживаемые решения, которые хорошо вписываются в общую архитектуру приложения.