open atlas
↑ К треку
Паттерны React RXP · 09 · 01

Когда мемоизировать

По умолчанию НЕ мемоизируй. Тянись к memo/useMemo/useCallback лишь когда идентичность уходит в memo-ребёнка, значение — зависимость другого хука или вычисление измеримо дорогое. Профилируй заранее, не рефлекторно.

RXP Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Открой почти любую React-кодовую базу, которую трогали больше двух человек, и ты это найдёшь: каждый колбэк обёрнут в useCallback, каждое производное значение — в useMemo, каждый компонент — в React.memo, применённые рефлекторно, «ради производительности». Никто не профилировал. Никто не может показать рендер, который стал быстрее. Приложение измеримо не ускорилось — оно измеримо труднее читается.

Этот рефлекс — режим отказа всего юнита, поэтому мы начинаем с того, что убиваем его. Мемоизация не бесплатна, она не значение по умолчанию, и применённая без причины она обычно чистый проигрыш — медленнее пересчёта, который она заменила, и шумнее кода, который она «оптимизировала». Этот урок — правило, которое держит остальной юнит честным: когда мемоизировать и — гораздо чаще — когда нет.

Цель

После этого урока ты можешь сформулировать senior-дефолт — не мемоизируй — и назвать ровно три ситуации, которые его переопределяют: идентичность, передаваемая в React.memo-ребёнка; значение, которое является зависимостью другого хука; или вычисление, измеримо дорогое. Ты можешь объяснить, почему сплошная мемоизация часто медленнее и всегда шумнее, использовать React Profiler, чтобы найти настоящую проблему до того, как тянуться к фиксу, и распознать, что большинство запахов «нам нужен memo» — это на самом деле плохое расположение состояния в маскировке.

1

По умолчанию — не надо. Мемоизация стоит дорого на каждом рендере, а большинство рендеров и так дешёвые. useMemo и useCallback не заставляют работу исчезнуть — они меняют работу на учёт. Каждый из них выделяет замыкание, хранит массив зависимостей и запускает на каждом рендере сравнение, чтобы решить, переиспользовать ли кэшированное значение. Для значения, дешёвого в пересчёте, этот учёт стоит дороже, чем просто пересчитать его.

// чистый шум: «дорогое» вычисление — это сложение
const total = useMemo(() => price + tax, [price, tax]);

// так быстрее, меньше и понятнее — просто вычисли
const total = price + tax;

React даже заново прогоняет сравнение зависимостей и держит замыкание живым на каждом рендере. Ты заплатил за кэш, чтобы избежать a + b. Senior-база — это обычное выражение; мемоизация — исключение, которое ты обязан оправдать, а не привычка, которую ты применяешь.

2

Переопределение 1 — идентичность важна, потому что значение переходит в React.memo-ребёнка. React.memo пропускает повторный рендер ребёнка, когда его пропсы поверхностно равны прошлому разу. Но новый объект, массив или функция-литерал — это совершенно новая ссылка на каждом рендере, так что она проваливает поверхностную проверку, и memo ничего не делает. Стабилизация идентичности этого пропса — единственный случай, когда useCallback/useMemo напрямую включает оптимизацию.

const Row = React.memo(function Row({ onSelect }: { onSelect: () => void }) {
  // перерисовывается только когда идентичность onSelect меняется
  return <button onClick={onSelect}>Select</button>;
});

function List({ items }: { items: Item[] }) {
  // БЕЗ useCallback: новая функция на каждом рендере → каждый memo'd Row перерисовывается всё равно
  const onSelect = useCallback(() => {/* ... */}, []);
  return items.map((i) => <Row key={i.id} onSelect={onSelect} />);
}

Ключевое слово — и: мемоизируй колбэк и оборачивай ребёнка в memo. Одно без другого — половина паттерна: useCallback, чей потребитель не мемоизирован, стабилизирует идентичность, которую никто не проверяет.

3

Переопределение 2 — значение является зависимостью другого хука, так что его идентичность управляет корректностью, а не только скоростью. Когда объект или функция появляется в массиве зависимостей useEffect, useMemo или useCallback, свежая идентичность на каждом рендере заново запускает эффект или инвалидирует кэш каждый раз. Здесь ты мемоизируешь, чтобы держать зависимость стабильной, и это про поведение — разбегающиеся эффекты, бесконечные циклы, — а не про микро-перф.

// options — новый объект на каждом рендере → эффект перезапускается каждый рендер
const options = useMemo(() => ({ pageSize, sort }), [pageSize, sort]);

useEffect(() => {
  const sub = subscribe(options);
  return () => sub.unsubscribe();
}, [options]); // теперь переподписывается только когда pageSize/sort реально меняются

Часто более чистый фикс — убрать зависимость (заинлайнить литерал внутрь эффекта или следовать «возможно, тебе не нужен эффект»), а не мемоизировать её. Мемоизируй зависимость только когда она действительно должна разделяться через границу эффекта.

4

Переопределение 3 — вычисление измеримо дорогое, и ты его измерил. Сортировка десяти тысяч строк, парсинг большого блоба, тяжёлый reduce на каждое нажатие клавиши — это настоящая работа, которую стоит кэшировать между рендерами. Несущее слово — измеримо: ты подтвердил это Profiler’ом или замером, ты не предположил. useMemo здесь пропускает настоящую работу CPU; useMemo на .filter по двадцати элементам не пропускает ничего и добавляет учёт.

// оправдано: фильтрация+сортировка 10k строк на каждое нажатие — настоящая работа
const visible = useMemo(
  () => rows.filter(matches(query)).sort(byName),
  [rows, query],
);

Если ты не можешь сказать, какое вычисление медленное и насколько, у тебя нет переопределения 3 — у тебя догадка. А догадка, оборачивающая дешёвый цикл в useMemo, делает компонент медленнее, а не быстрее.

Разбор примера

Настоящий «перф»-PR — большую часть надо удалить. Коллега ничего не профилировал и «оптимизировал» панель поиска. Вот «до»:

function SearchPanel({ rows }: { rows: Row[] }) {
  const [query, setQuery] = useState("");

  // каждая строка мемоизирована «ради производительности»
  const onChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value),
    [],
  );
  const upper = useMemo(() => query.toUpperCase(), [query]);
  const placeholder = useMemo(() => `Search ${rows.length} rows`, [rows.length]);
  const visible = useMemo(
    () => rows.filter((r) => r.name.includes(query)),
    [rows, query],
  );

  return (
    <>
      <input value={query} onChange={onChange} placeholder={placeholder} />
      <p>{upper}</p>
      <PlainList rows={visible} /> {/* PlainList НЕ обёрнут в memo */}
    </>
  );
}

Теперь применим трое ворот. onChange — воротам 1 нужен memo-потребитель; <input> — это host-элемент, так что useCallback стабилизирует идентичность, которую никто не проверяет → удалить. upper и placeholder — строковые операции, не измеримо дорогие, без memo-ребёнка, без зависимости → удалить. visible — ворота 3, если rows достаточно велик, чтобы это имело значение; но его потребитель PlainList не мемоизирован, так что сохранённая идентичность массива не покупает и пропущенного рендера. Честный ход — профилировать: если фильтр дешёвый, выброси useMemo; если rows действительно большой, оставь его и оберни PlainList в memo, чтобы стабильная идентичность реально окупилась.

function SearchPanel({ rows }: { rows: Row[] }) {
  const [query, setQuery] = useState("");
  const visible = useMemo(
    () => rows.filter((r) => r.name.includes(query)),
    [rows, query],
  ); // оставлено ТОЛЬКО потому, что профилирование показало: rows большой

  return (
    <>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={`Search ${rows.length} rows`}
      />
      <p>{query.toUpperCase()}</p>
      <MemoList rows={visible} /> {/* теперь memo'd, так что стабильная идентичность важна */}
    </>
  );
}

Четыре memo стали одним — и единственный выживший спарен с memo-ребёнком, так что он реально что-то делает. Меньше кода, та же или лучшая производительность, а оставшийся useMemo документирует настоящую стоимость вместо рефлекса.

Почему это работает

Почему сплошная мемоизация часто медленнее, а не просто нейтральна? Потому что каждый useMemo/useCallback делает настоящую работу на каждом рендере: он выделяет замыкание, которое ты ему передаёшь, держит его живым, хранит массив зависимостей и прогоняет цикл сравнения на каждом рендере, чтобы решить — попадание в кэш или промах. Для дешёвого значения этот учёт превышает пересчёт, которого он избегает, — ты добавил работу, чтобы пропустить работу, которая была меньше. Он также блокирует сборку мусора кэшированного значения и добавляет массив зависимостей, который ты обязан держать корректным навсегда. Итог: больше аллокаций, больше сравнений, больше сопровождения — ради вычисления, которое было несколько наносекунд. (React Compiler меняет эту арифметику, мемоизируя автоматически там, где это безопасно, — но это компилятор, доказывающий необходимость, а не ты, рассыпающий memo вручную.)

Частая ошибка

Самая глубокая ошибка — относиться к «мы слишком много перерисовываемся» как к проблеме мемоизации, когда почти всегда это проблема расположения состояния. Кусок состояния, сидящий слишком высоко — наверху страницы, когда им пользуется только листовой input, — заставляет всё поддерево перерисовываться на каждое нажатие клавиши. Рефлекторный фикс — распылить memo по детям, чтобы заблокировать распространение. Senior-фикс — спустить состояние вниз, туда, где оно используется (колокация), или вынести контент как children, чтобы он не пересоздавался, — и перерисовки исчезают с нулём memo. Тянись к Profiler первым: если flamegraph показывает широкое дерево, перерисовывающееся от высокого обновления состояния, исправь, где живёт состояние, прежде чем что-либо кэшировать. Мемоизация латает симптом; правильное расположение состояния убирает причину.

Проверь себя
Викторина

Ты добавляешь useCallback к обработчику и передаёшь его дочернему компоненту, чтобы «остановить перерисовку ребёнка». Ребёнок — обычный (не memo'd) компонент. Что происходит на самом деле?

Итог

Senior-дефолт — не мемоизируй: useMemo, useCallback и React.memo все несут стоимость на каждом рендере (аллокация, хранимый массив зависимостей, сравнение), и применённые рефлекторно они обычно медленнее пересчёта, который они заменяют, и всегда труднее читаются. Переопредели дефолт ровно в трёх случаях: (1) идентичность, переходящая в React.memo-ребёнка, — и только в паре с этим memo; (2) значение, являющееся зависимостью другого хука, где стабильность про корректность, а не про скорость; (3) вычисление, которое ты измерил как дорогое. Если ни один из трёх не выполняется, memo — чистый шум. И прежде чем вообще оптимизировать — профилируй: широкое дерево, перерисовывающееся на каждое нажатие клавиши, почти всегда плохое расположение состояния, исправляемое спуском состояния вниз или подъёмом контента в children, а не распылением кэшей по симптому. Мемоизируй по доказательствам, а не по привычке.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.