useMemo и useCallback: когда они реально нужны, а когда мешают
На каждом code-review я вижу одну и ту же картину: половина функций компонента обёрнута в useCallback, половина значений — в useMemo. Спрашиваешь «зачем» — слышишь «для оптимизации, чтобы лишний раз не пересчитывалось». Спрашиваешь «измерял?» — нет.
Большинство useMemo и useCallback в проектах ничего не оптимизируют, а только добавляют шум и риск багов с устаревшими зависимостями. Расскажу, когда они реально нужны, а когда их стоит снести и не вспоминать.
Что эти хуки делают на самом деле
useMemo(fn, deps) — кеширует результат вычисления и возвращает тот же объект, пока не поменялись зависимости. useCallback(fn, deps) — то же самое, только для функций (это, по сути, синтаксический сахар над useMemo).
Обе они сами по себе не делают рендер быстрее. Они дают тебе стабильную ссылку. Польза от стабильной ссылки появляется только в трёх случаях:
- Эта ссылка уезжает в
React.memo-обёрнутый дочерний компонент, и ты хочешь, чтобы он не ререндерился впустую. - Эта ссылка попадает в зависимости
useEffect, и без стабильности эффект перезапускался бы на каждом рендере. - Это вычисление реально дорогое (большой массив, тяжёлая фильтрация, парсинг).
Во всех остальных случаях useMemo ничего не экономит. JS-движок отлично создаёт объекты и функции в памяти — это не та операция, ради которой стоит усложнять код.
Случай 1: дорогие вычисления
function Catalog({ products, query }: Props) {
// тут реально много данных и сложная сортировка
const filtered = useMemo(() => {
return products
.filter((p) => matches(p, query))
.sort((a, b) => relevance(b, query) - relevance(a, query));
}, [products, query]);
return <ProductGrid items={filtered} />;
}
Если в массиве 5000 товаров и сортировка занимает 30 мс, useMemo экономит эти 30 мс на каждом рендере, не связанном с изменением products или query. Профайлер это покажет.
Если массив 50 товаров — экономии нет, удаляй useMemo.
Случай 2: зависимости useEffect
function UserSearch() {
const [name, setName] = useState("");
// Создаём объект для запроса
const filters = { name, role: "admin" }; // ❌ новый объект на каждый рендер
useEffect(() => {
fetchUsers(filters);
}, [filters]); // эффект запускается на каждый рендер
}
Тут проблема: filters создаётся заново на каждом рендере, у него новая ссылка, эффект думает, что зависимость поменялась, и запускает запрос. Бесконечно.
Решений два. Можно завернуть filters в useMemo. А можно вообще не создавать промежуточный объект и положить примитивы в зависимости:
useEffect(() => {
fetchUsers({ name, role: "admin" });
}, [name]);
Второй вариант почти всегда лучше: меньше кода, нет места для багов с устаревшими ссылками.
Случай 3: пропсы для React.memo-обёрнутого ребёнка
const Heavy = React.memo(function Heavy({ onClick, items }: Props) {
// тяжёлый рендер большого графика
});
function Parent() {
const [count, setCount] = useState(0);
const onClick = useCallback(() => {
/* что-то делаем */
}, []);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Heavy onClick={onClick} items={items} />
</>
);
}
Без useCallback функция onClick создаётся заново на каждый рендер Parent. React.memo по умолчанию сравнивает пропсы по ссылке, видит «новая ссылка — обновляю» и ререндерит Heavy. useCallback тут реально нужен.
Но: если Heavy не обёрнут в React.memo, useCallback не даёт ничего. Он стабилизирует ссылку, которую никто не сравнивает.
Случай, когда useMemo меняет поведение
Иногда useMemo используют не ради скорости, а ради того, чтобы один и тот же объект не пересоздавался. Например, дефолтный объект:
const DEFAULT_OPTIONS = { sort: "asc" } as const;
function List({ options = DEFAULT_OPTIONS }: Props) {
useEffect(() => {
init(options);
}, [options]);
}
Здесь стабильная ссылка важна для корректности, не для скорости. Если бы дефолт писали как options = { sort: "asc" } прямо в параметре, он бы создавался каждый раз и эффект гонял туда-сюда.
В таких случаях useMemo внутри тоже сработает, но я предпочитаю выносить константу за пределы компонента, как в примере выше. Это понятнее и не требует хука.
Что я делаю, когда вижу useMemo/useCallback на ревью
Спрашиваю автора три вопроса:
- Получатель пропса завёрнут в
React.memo? - Эта ссылка идёт в зависимости
useEffect? - Вычисление реально дорогое (мерил)?
Если на все три «нет» — прошу убрать. Не из вредности, а потому что:
- Сам хук стоит не нуль (хоть и копейки).
- Список зависимостей — место для багов. Забыл что-то добавить — получил устаревшее значение.
- Код становится шумнее, читать тяжелее.
Грабли с зависимостями
Самая частая беда в обоих хуках — устаревшие зависимости. Ты пишешь функцию, которая использует переменную userId, но в массив зависимостей её не положил:
const onClick = useCallback(() => {
api.delete(userId);
}, []); // ❌ userId не в зависимостях
В первом рендере userId = «1». Функция запоминает его. Дальше userId меняется на «2», но onClick всё ещё помнит «1» — потому что зависимостей не было, обновлять не надо. Пользователь нажимает — удаляется не тот юзер.
ESLint-правило react-hooks/exhaustive-deps ловит такое автоматически. Включи его и не отключай через eslint-disable просто чтобы убрать предупреждение. Лучше переписать.
Когда писать useEvent или ref-обёртку
Иногда хочется стабильной функции, которая всегда видит свежие значения. На сегодня официального useEvent нет, но паттерн известный:
function useStableCallback<T extends (...args: any[]) => any>(fn: T): T {
const ref = useRef(fn);
useEffect(() => {
ref.current = fn;
});
return useCallback(((...args) => ref.current(...args)) as T, []);
}
На выходе — функция со стабильной ссылкой, которая всегда вызывает свежую версию. Полезно для обработчиков, которые ставятся в долгоживущие подписки. Но не злоупотребляй: это уже специальный инструмент, не на каждый чих.
Что меняет React Compiler
В React 19 экспериментально появился compiler, который делает мемоизацию автоматически. Если он включён, useMemo и useCallback в большинстве мест становятся не нужны — компилятор сам решит, что и где кешировать.
На моих проектах compiler пока стоит точечно. Где включён — я перестала писать useCallback руками вообще, и код стал чище. Где не стоит — продолжаю руководствоваться правилами выше.
Что запомнить
Не оборачивай в useMemo и useCallback «на всякий случай». Эти хуки работают только в трёх сценариях: дорогие вычисления, стабильные зависимости для эффектов, мемоизированные дети. Везде остальное — это шум, который тебе придётся поддерживать и который однажды выстрелит устаревшей зависимостью.
Если сомневаешься — не оборачивай. Открой профайлер, посмотри, есть ли реальный лаг, и только потом мемоизируй конкретное место. Преждевременная оптимизация в React стоит дороже, чем кажется, потому что её ты вычистишь не сразу.