lenec ru

← все посты

useMemo и useCallback: когда они реально нужны, а когда мешают

10K

На каждом code-review я вижу одну и ту же картину: половина функций компонента обёрнута в useCallback, половина значений — в useMemo. Спрашиваешь «зачем» — слышишь «для оптимизации, чтобы лишний раз не пересчитывалось». Спрашиваешь «измерял?» — нет.

Большинство useMemo и useCallback в проектах ничего не оптимизируют, а только добавляют шум и риск багов с устаревшими зависимостями. Расскажу, когда они реально нужны, а когда их стоит снести и не вспоминать.

Что эти хуки делают на самом деле

useMemo(fn, deps) — кеширует результат вычисления и возвращает тот же объект, пока не поменялись зависимости. useCallback(fn, deps) — то же самое, только для функций (это, по сути, синтаксический сахар над useMemo).

Обе они сами по себе не делают рендер быстрее. Они дают тебе стабильную ссылку. Польза от стабильной ссылки появляется только в трёх случаях:

  1. Эта ссылка уезжает в React.memo-обёрнутый дочерний компонент, и ты хочешь, чтобы он не ререндерился впустую.
  2. Эта ссылка попадает в зависимости useEffect, и без стабильности эффект перезапускался бы на каждом рендере.
  3. Это вычисление реально дорогое (большой массив, тяжёлая фильтрация, парсинг).

Во всех остальных случаях 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 на ревью

Спрашиваю автора три вопроса:

  1. Получатель пропса завёрнут в React.memo?
  2. Эта ссылка идёт в зависимости useEffect?
  3. Вычисление реально дорогое (мерил)?

Если на все три «нет» — прошу убрать. Не из вредности, а потому что:

  • Сам хук стоит не нуль (хоть и копейки).
  • Список зависимостей — место для багов. Забыл что-то добавить — получил устаревшее значение.
  • Код становится шумнее, читать тяжелее.

Грабли с зависимостями

Самая частая беда в обоих хуках — устаревшие зависимости. Ты пишешь функцию, которая использует переменную 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 стоит дороже, чем кажется, потому что её ты вычистишь не сразу.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.