Когда мемоизировать
По умолчанию НЕ мемоизируй. Тянись к memo/useMemo/useCallback лишь когда идентичность уходит в memo-ребёнка, значение — зависимость другого хука или вычисление измеримо дорогое. Профилируй заранее, не рефлекторно.
Открой почти любую React-кодовую базу, которую трогали больше двух человек, и ты это найдёшь: каждый колбэк обёрнут в useCallback, каждое производное значение — в useMemo, каждый компонент — в React.memo, применённые рефлекторно, «ради производительности». Никто не профилировал. Никто не может показать рендер, который стал быстрее. Приложение измеримо не ускорилось — оно измеримо труднее читается.
Этот рефлекс — режим отказа всего юнита, поэтому мы начинаем с того, что убиваем его. Мемоизация не бесплатна, она не значение по умолчанию, и применённая без причины она обычно чистый проигрыш — медленнее пересчёта, который она заменила, и шумнее кода, который она «оптимизировала». Этот урок — правило, которое держит остальной юнит честным: когда мемоизировать и — гораздо чаще — когда нет.
После этого урока ты можешь сформулировать senior-дефолт — не мемоизируй — и назвать ровно три ситуации, которые его переопределяют: идентичность, передаваемая в React.memo-ребёнка; значение, которое является зависимостью другого хука; или вычисление, измеримо дорогое. Ты можешь объяснить, почему сплошная мемоизация часто медленнее и всегда шумнее, использовать React Profiler, чтобы найти настоящую проблему до того, как тянуться к фиксу, и распознать, что большинство запахов «нам нужен memo» — это на самом деле плохое расположение состояния в маскировке.
По умолчанию — не надо. Мемоизация стоит дорого на каждом рендере, а большинство рендеров и так дешёвые. useMemo и useCallback не заставляют работу исчезнуть — они меняют работу на учёт. Каждый из них выделяет замыкание, хранит массив зависимостей и запускает на каждом рендере сравнение, чтобы решить, переиспользовать ли кэшированное значение. Для значения, дешёвого в пересчёте, этот учёт стоит дороже, чем просто пересчитать его.
// чистый шум: «дорогое» вычисление — это сложение
const total = useMemo(() => price + tax, [price, tax]);
// так быстрее, меньше и понятнее — просто вычисли
const total = price + tax;React даже заново прогоняет сравнение зависимостей и держит замыкание живым на каждом рендере. Ты заплатил за кэш, чтобы избежать a + b. Senior-база — это обычное выражение; мемоизация — исключение, которое ты обязан оправдать, а не привычка, которую ты применяешь.
Переопределение 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, чей потребитель не мемоизирован, стабилизирует идентичность, которую никто не проверяет.
Переопределение 2 — значение является зависимостью другого хука, так что его идентичность управляет корректностью, а не только скоростью. Когда объект или функция появляется в массиве зависимостей useEffect, useMemo или useCallback, свежая идентичность на каждом рендере заново запускает эффект или инвалидирует кэш каждый раз. Здесь ты мемоизируешь, чтобы держать зависимость стабильной, и это про поведение — разбегающиеся эффекты, бесконечные циклы, — а не про микро-перф.
// options — новый объект на каждом рендере → эффект перезапускается каждый рендер
const options = useMemo(() => ({ pageSize, sort }), [pageSize, sort]);
useEffect(() => {
const sub = subscribe(options);
return () => sub.unsubscribe();
}, [options]); // теперь переподписывается только когда pageSize/sort реально меняютсяЧасто более чистый фикс — убрать зависимость (заинлайнить литерал внутрь эффекта или следовать «возможно, тебе не нужен эффект»), а не мемоизировать её. Мемоизируй зависимость только когда она действительно должна разделяться через границу эффекта.
Переопределение 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.