Мемоизация, ссылки и редьюсеры: useMemo, useCallback, useRef, useReducer
В предыдущей главе мы выяснили, что рендер родителя по умолчанию рендерит всех детей. Теперь — инструменты, которые позволяют это остановить: useMemo, useCallback, React.memo. Плюс два хука, которые решают смежные задачи: useRef — мутабельная ячейка, переживающая рендеры без ререндеров, и useReducer — альтернативная модель состояния через чистые переходы. У всех четырёх одна общая черта: их назначение понятно только на фоне механики рендеринга, поэтому глава опирается на предыдущую.
Референциальная стабильность: суть проблемы
Заголовок раздела «Референциальная стабильность: суть проблемы»JavaScript сравнивает объекты и функции по ссылке, а не по содержимому:
{} === {} // false — два разных объекта(() => {}) === (() => {}) // false — две разные функцииКаждый рендер — это новый вызов функции компонента. Все локальные переменные создаются заново. Объект { name: 'Ann' }, объявленный в теле компонента, при каждом рендере — новый объект, даже если содержимое идентично. Для самого React это неважно (сравнение пропсов идёт по Object.is), но вот если дочерний компонент обёрнут в React.memo, или объект попал в зависимости useEffect — разница между «та же ссылка» и «новая ссылка» решает всё:
function Parent() { const [query, setQuery] = useState('');
// КАЖДЫЙ рендер — новая функция, даже если код тот же const handleSelect = (id: string) => { console.log(id); };
// КАЖДЫЙ рендер — новый объект const style = { color: 'red' };
return <Child onSelect={handleSelect} style={style} />;}
const Child = React.memo(function Child(props: { onSelect: (id: string) => void; style: React.CSSProperties;}) { // без мемоизации Parent рендерился бы при каждом вводе в query return <GrandChild {...props} />;});React.memo сравнивает пропсы по ссылкам. handleSelect и style новые на каждом рендере → мемоизация Child бесполезна → он рендерится вместе с родителем на каждый введённый символ. Решение — стабилизировать ссылки:
// Ссылка стабильна, пока не изменится query (а тут query не нужен)const handleSelect = useCallback((id: string) => { console.log(id);}, []);
// Ссылка стабильна навсегда (примитив в замыкании не меняется)const style = useMemo(() => ({ color: 'red' }), []);Вот главный вывод, который нужно вынести из главы: useMemo и useCallback существуют не для «ускорения вычислений». Их причина — референциальная стабильность. Вычисление [...items].sort() само по себе микросекундное; мемоизировать его ради экономии — бессмысленно. Мемоизировать имеет смысл, только когда нестабильная ссылка ломает что-то внизу: пробивает React.memo, перезапускает эффект, триггерит лишний рендер тяжёлого поддерева.
useMemo: когда полезен, когда бесполезен
Заголовок раздела «useMemo: когда полезен, когда бесполезен»Полезен в трёх сценариях:
- Тяжёлое вычисление, которое зависит от редко меняющихся данных. Большой список сортировки/фильтрации. Здесь выигрыш — не столько в «повторном вычислении», сколько в отсутствии лишней работы на каждый рендер.
const sortedProducts = useMemo( () => [...products].sort((a, b) => a.price - b.price), [products],);- Стабилизация объекта/массива, уходящего в пропсы мемоизированного ребёнка или в зависимости эффекта (главный случай!).
// Без useMemo фильтр перезапускал бы эффект на каждый рендерconst activeFilters = useMemo( () => ({ status, category }), [status, category],);
useEffect(() => { loadProducts(activeFilters);}, [activeFilters]);- useMemo как гарантия для ESLint. Иногда значение не тяжёлое, но его нужно положить в зависимости, и линтер требует стабильности.
useMemoчестнее, чем eslint-disable.
Когда бесполезен или вреден:
- Значение дешёвое (конкатенация строк, простой фильтр малого массива) — мемоизация стоит дороже вычисления.
- Значение используется один раз прямо здесь, никуда не передаётся — стабильность не нужна.
- Массовая мемоизация «на всякий случай» — каждый
useMemo/useCallbackусложняет чтение, и на большом компоненте это превращается в кашу зависимостей, которую невозможно поддерживать.
useCallback: мемоизация функций
Заголовок раздела «useCallback: мемоизация функций»useCallback(fn, deps) — это синтаксический сахар над useMemo(() => fn, deps). Единственная причина существования — функции, которые уходят вниз по дереву или в зависимости эффектов:
// Стабильная функция — можно передать в React.memo-компонентconst onProductSelect = useCallback((id: string) => { setSelected((prev) => (prev === id ? null : id));}, []); // не зависит ни от чего — стабильна навсегда
// Или в зависимости эффекта без перезапусковuseEffect(() => { window.addEventListener('keydown', onEscape); return () => window.removeEventListener('keydown', onEscape);
function onEscape(e: KeyboardEvent) { if (e.key === 'Escape') onProductSelect(''); }}, [onProductSelect]);Классическая ошибка — оборачивать в useCallback каждый обработчик, «чтобы было быстрее». Лишний useCallback не замедляет, но и не ускоряет; он просто мусор. Правило: оборачивай, когда функция уходит в мемоизированный дочерний компонент или в зависимости эффекта. Иначе — обычная функция в теле компонента.
React.memo: где проходит граница мемоизации
Заголовок раздела «React.memo: где проходит граница мемоизации»useMemo/useCallback стабилизируют значения, React.memo — компонент. Мемоизированный компонент пропускает рендер, если все пропсы равны по ссылкам:
const ProductCard = React.memo(function ProductCard({ product, onSelect }: Props) { return <button onClick={() => onSelect(product.id)}>{product.name}</button>;}, (prev, next) => prev.product.id === next.product.id && prev.onSelect === next.onSelect);Второй аргумент React.memo — компаратор (по умолчанию поверхностное сравнение всех пропсов). Три практических замечания:
- Не мемоизируй компонент, который рендерится за микросекунды. Проверка пропсов тоже стоит денег; на дешёвых компонентах
memo— чистый оверхед. - Компаратор не отменяет нестабильные пропсы. Если родитель передаёт inline-функции и объекты, мемоизация пробивается — сначала стабилизируй ссылки (useCallback/useMemo у родителя), потом memo.
- children ломают memo.
<MemoCard><Icon /></MemoCard>—childrenэто новый элемент на каждом рендере родителя → сравнение всегда «не равно». Мемоизируй children отдельно или принимай данные пропсами, а не разметкой.
Где memo реально окупается: большие статичные поддеревья внутри часто обновляемого родителя (сайдбар рядом с поиском), тяжёлые строки списков, графики. Где нет: листовые элементы, компоненты с дешёвым рендером.
Измерение: не гадай
Заголовок раздела «Измерение: не гадай»Как понять, есть ли выигрыш? React DevTools Profiler из прошлой главы. Записываешь сценарий (ввод в поиск), смотришь, сколько времени уходит на рендер поддерева. Затем добавляешь мемоизацию и записываешь снова. Числа решают: если рендер Child занимал 0.3 мс — мемоизация не нужна была; если 30 мс на списке из 500 элементов — нужна.
Есть и более низкоуровневый инструмент — performance.now() вокруг вычисления в dev-режиме. Грубо, но для «пара миллисекунд или двести» сгодится.
useRef: мутабельная ячейка вне перерендеров
Заголовок раздела «useRef: мутабельная ячейка вне перерендеров»useRef возвращает объект { current: T }, который:
- Стабилен между рендерами — тот же самый объект каждый раз.
- Мутация
.currentне вызывает ререндер. - Живёт, пока компонент смонтирован — переживает рендеры, теряется при размонтировании.
Из этих трёх свойств вытекают два применения.
Применение 1: доступ к DOM
Заголовок раздела «Применение 1: доступ к DOM»const inputRef = useRef<HTMLInputElement>(null);
// ref-объект передаётся в атрибут — React сам положит DOM-узел в .current<input ref={inputRef} />;// и вынет (null) при размонтировании
const focusInput = () => inputRef.current?.focus();После фазы commit React записывает в .current реальный DOM-узел. Мутация .current изнутри — единственный штатный способ держать в ref «живой» DOM. Не пытайся хранить там данные, от которых зависит рендер: изменение ref не перерисует компонент, и ты увидишь устаревшее значение.
Применение 2: невидимое значение между рендерами
Заголовок раздела «Применение 2: невидимое значение между рендерами»Таймеры, предыдущие значения пропсов, «флаг инициализации», инстансы сторонних библиотек — всё, что нужно помнить, но не показывать:
// Храним id таймера между рендерами, не рендерясь на его изменениеconst timerRef = useRef<number | null>(null);
useEffect(() => { timerRef.current = window.setInterval(() => setTick((t) => t + 1), 1000); return () => { if (timerRef.current !== null) clearInterval(timerRef.current); };}, []);
// Предыдущее значение пропса (паттерн «запомнить прошлое»)const prevStatusRef = useRef(status);useEffect(() => { prevStatusRef.current = status;});const prevStatus = prevStatusRef.current; // в этом рендере — ещё староеЕщё один важный кейс: ref как «последнее актуальное значение», читаемое из стабильного callback/эффекта, который линтер не заставляет переподписывать:
// Стабильный callback, который всегда видит свежий queryconst queryRef = useRef(query);queryRef.current = query; // обновляем каждый рендер — рендер чистый, присваивание ссылке — норм
const stableSearch = useCallback(() => { fetch(`/api/search?q=${queryRef.current}`); // читает актуальное значение}, []); // пустые зависимости — но без рассинхронаCallback-форма ref
Заголовок раздела «Callback-форма ref»Иногда нужно не хранить узел, а выполнить код при его появлении/исчезновении (измерить размеры, подключить стороннюю библиотеку). Атрибут ref принимает и функцию:
// Callback-форма: вызывается с DOM-узлом при монтировании и с null при размонтировании<div ref={(node) => { if (node) { // узел появился: измеряем, подписываем плагин console.log(node.getBoundingClientRect()); } }}/>Полезный трюк: callback-форма с useCallback и пустыми зависимостями даёт стабильную ref-функцию — React не будет вызывать её заново с null→node на каждый рендер (что случается с inline-функциями и портит, например, измерения).
useReducer: dispatch-модель
Заголовок раздела «useReducer: dispatch-модель»useReducer — альтернатива useState для состояния, где много связанных переходов. Логика — в чистой функции-редьюсере вне компонента; внутри — только dispatch.
type Status = 'idle' | 'loading' | 'success' | 'error';
interface State { status: Status; data: User | null; error: string | null;}
type Action = | { type: 'fetch/start' } | { type: 'fetch/success'; payload: User } | { type: 'fetch/error'; error: string };
function reducer(state: State, action: Action): State { switch (action.type) { case 'fetch/start': return { ...state, status: 'loading', error: null }; case 'fetch/success': return { status: 'success', data: action.payload, error: null }; case 'fetch/error': return { status: 'error', data: null, error: action.error }; }}
const [state, dispatch] = useReducer(reducer, { status: 'idle', data: null, error: null,});
// в коде:dispatch({ type: 'fetch/start' });Почему это мощнее пары сеттеров:
- Все переходы явные. По
dispatch({ type: 'fetch/error' })видно, что случится со состоянием, — в отличие от трёх разрозненныхsetStatus,setData,setError, которые могут вызываться в разном порядке и оставлять рассинхрон («loading и error одновременно»). - Редьюсер — чистая функция. Тестируется без React:
(state, action) => state. Юнит-тест на каждый кейс — три строчки. - Состояние согласовано по построению. Невозможно забыть сбросить
errorпри новом запросе — редьюсер это делает атомарно.
useReducer + Context: масштабирование
Заголовок раздела «useReducer + Context: масштабирование»Паттерн, предшествующий Zustand: reducer на верхнем уровне, dispatch раздаётся через контекст, дочерние компоненты шлют actions, не зная об имплементации. В проде это почти вытеснено библиотеками состояния (глава 5), но знать полезно — встретишь в legacy-коде, а концепция dispatch-модели напрямую перекликается с Redux.
Композиция редьюсеров
Заголовок раздела «Композиция редьюсеров»Сложное состояние складывается из независимых кусков — как combineReducers в Redux, только руками. Каждый под-редьюсер отвечает за свой кусок, корневой собирает результат:
interface WizardState { step: number; form: { name: string; email: string };}
type WizardAction = | { type: 'step/next' } | { type: 'step/prev' } | { type: 'form/set'; field: 'name' | 'email'; value: string };
function stepReducer(state: number, action: WizardAction): number { if (action.type === 'step/next') return state + 1; if (action.type === 'step/prev') return Math.max(0, state - 1); return state; // не наши actions — не трогаем (как в Redux)}
function formReducer(state: WizardState['form'], action: WizardAction): WizardState['form'] { if (action.type === 'form/set') return { ...state, [action.field]: action.value }; return state;}
function wizardReducer(state: WizardState, action: WizardAction): WizardState { return { step: stepReducer(state.step, action), form: formReducer(state.form, action), };}Принцип «неизвестный action → вернуть state как есть» позволяет каждому под-редьюсеру видеть весь поток действий, но реагировать только на свои. Результат тот же, что и в Redux: изоляция изменений, тестируемость каждого куска отдельно, предсказуемые переходы.
useRef на практике: восстановление позиции скролла
Заголовок раздела «useRef на практике: восстановление позиции скролла»Ещё один узнаваемый сценарий для ref — значение, которое нужно при размонтировании, но не во время рендеров. Классика чатов и лент: при уходе со страницы запоминаем скролл, при возврате восстанавливаем:
function FeedPage() { const scrollRef = useRef(0); const listRef = useRef<HTMLDivElement>(null);
useEffect(() => { // при монтировании — восстановили сохранённую позицию listRef.current?.scrollTo({ top: scrollRef.current });
const onScroll = () => { scrollRef.current = listRef.current?.scrollTop ?? 0; // пишем в ref — без ререндеров }; listRef.current?.addEventListener('scroll', onScroll, { passive: true });
return () => { // при размонтировании ref всё ещё хранит последнее значение — // React Router remount вернёт нас сюда, и позиция восстановится listRef.current?.removeEventListener('scroll', onScroll); }; }, []); // ...}Скролл меняется десятки раз в секунду — положить его в state было бы катастрофой. Ref здесь идеален: мутация дешёвая, читается при монтировании, живёт между монтированиями. Ограничение помни: если бы позицию нужно было показывать (индикатор «прочитано 60%»), понадобился бы state/throttle.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Мемоизация всего подряд «для скорости». Десять
useCallbackна компонент — читаемость в минус, выигрыша ноль. Мемоизируй измеренное и то, что уходит вниз по дереву. - Свежие значения в старом callback.
useCallbackс пустыми зависимостями, читающий стейт — получит его снапшот на момент создания. Лечится добавлением зависимостей (и пересозданием callback’а) или чтением через ref. - Хранение данных рендера в ref. Значение, от которого зависит JSX, в ref не клади: мутация не вызовет ререндер, и интерфейс покажет устаревшее. Это ловушка для тех, кто «не хочет лишних рендеров».
- Забытый cleanup для таймеров в ref. Ref не освобождает ресурсы сам — очистка по-прежнему в
useEffectcleanup. - Reducer с мутацией.
state.items.push(x); return state— React увидит ту же ссылку и проигнорирует обновление. Редьюсер обязан возвращать новый объект (или использовать Immer). - Dispatch логики прямо в JSX. Обработчик
onClick={() => dispatch(...)}с вычислениями — выноси в named-функцию или кастомный хук, иначе логика размазывается по разметке.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»В чём разница между useMemo и useCallback?
Технически useCallback(fn, deps) === useMemo(() => fn, deps). Разница в семантике: useMemo мемоизирует результат вычисления, useCallback — саму функцию. Используются вместе для референциальной стабильности.
Когда useMemo бесполезен? Когда значение дешёвое в вычислении и/или используется только локально без передачи в мемоизированные дети или зависимости эффектов. Мемоизация сама стоит ресурсов; её смысл — стабильность ссылок, а не «кэширование».
Почему изменение ref.current не вызывает ререндер?
Ref — escape hatch вне реактивной модели: React не отслеживает мутации .current. Поэтому ref подходит для DOM, таймеров, предыдущих значений, но не для данных, отображаемых в UI.
Что такое callback-форма ref и зачем она?
Атрибут ref может принимать функцию (node) => void, которую React вызывает с DOM-узлом при монтировании и с null при размонтировании. Нужна для императивных действий при появлении узла (измерения, инициализация плагинов) без ререндеров.
Когда выбрать useReducer вместо useState? Когда состояние — конечный автомат или содержит связанные поля, обновляемые вместе (статусы загрузки, корзина, многошаговая форма). Reducer собирает переходы в одном месте, делает их атомарными и тестируемыми.
Как редьюсер помогает против рассинхрона статусов?
Три независимых сеттера (setStatus, setData, setError) можно вызвать в любом порядке и забыть один. Reducer применяет действие как атомарный переход целиком: «fetch/error» всегда ставит все три поля согласованно.
Что важно про зависимости useCallback, передаваемого в эффект? Эффект, использующий callback, должен объявить его в зависимостях. Если callback стабилен (правильные deps у useCallback) — эффект не перезапустится лишний раз. Если callback пересоздаётся каждый рендер — эффект тоже.
Практика
Заголовок раздела «Практика»- Поймай пробитую мемоизацию. Собери Parent → (memo)Child. Передай в Child inline-функцию и inline-объект. Проверь через Profiler, что Child рендерится на каждый символ в Parent. Стабилизируй ссылки через
useCallback/useMemo— дети затихли. Объясни, почему. - Ref-таймер с cleanup. Реализуй секундомер: кнопки «старт/стоп/сброс», интервал в
useRef, очистка в эффекте. Проверь под StrictMode отсутствие утечек интервалов. - useDebounce без библиотеки. Напиши хук
useDebouncedValue(value, delay)наuseRef(таймер) +useEffect(cleanup). Используй для поиска: запрос уходит через 300 мс после остановки ввода. - Машина состояний загрузки. Перепиши компонент с тремя
useState(status/data/error) наuseReducer. Добавь кейс «refetch» (сброс error, сохранение старых data). Напиши юнит-тест на редьюсер без рендеринга React. - Callback-ref для измерений. Компонент-аккордеон: при раскрытии измерь высоту содержимого через callback-форма ref и задай её inline-style для анимации. Объясни, почему inline-функция в ref пересоздаётся и как это обойти.
Что почитать
Заголовок раздела «Что почитать»- React Dev: useMemo и useCallback — официальные справочники с разбором «когда НЕ нужны».
- React Dev: useRef — рефы и их ограничения.
- React Dev: useReducer — справочник и паттерны.
- React Dev: React.memo — как работает поверхностное сравнение и взаимодействие с мемоизированными пропсами.
- Kent C. Dodds: useMemo and useCallback — честный разбор, когда эти хуки реально нужны, а когда — ритуальная магия.