Перейти к содержимому

Мемоизация, ссылки и редьюсеры: 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, перезапускает эффект, триггерит лишний рендер тяжёлого поддерева.

Полезен в трёх сценариях:

  1. Тяжёлое вычисление, которое зависит от редко меняющихся данных. Большой список сортировки/фильтрации. Здесь выигрыш — не столько в «повторном вычислении», сколько в отсутствии лишней работы на каждый рендер.
const sortedProducts = useMemo(
() => [...products].sort((a, b) => a.price - b.price),
[products],
);
  1. Стабилизация объекта/массива, уходящего в пропсы мемоизированного ребёнка или в зависимости эффекта (главный случай!).
// Без useMemo фильтр перезапускал бы эффект на каждый рендер
const activeFilters = useMemo(
() => ({ status, category }),
[status, category],
);
useEffect(() => {
loadProducts(activeFilters);
}, [activeFilters]);
  1. useMemo как гарантия для ESLint. Иногда значение не тяжёлое, но его нужно положить в зависимости, и линтер требует стабильности. useMemo честнее, чем eslint-disable.

Когда бесполезен или вреден:

  • Значение дешёвое (конкатенация строк, простой фильтр малого массива) — мемоизация стоит дороже вычисления.
  • Значение используется один раз прямо здесь, никуда не передаётся — стабильность не нужна.
  • Массовая мемоизация «на всякий случай» — каждый useMemo/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 не замедляет, но и не ускоряет; он просто мусор. Правило: оборачивай, когда функция уходит в мемоизированный дочерний компонент или в зависимости эффекта. Иначе — обычная функция в теле компонента.

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 — компаратор (по умолчанию поверхностное сравнение всех пропсов). Три практических замечания:

  1. Не мемоизируй компонент, который рендерится за микросекунды. Проверка пропсов тоже стоит денег; на дешёвых компонентах memo — чистый оверхед.
  2. Компаратор не отменяет нестабильные пропсы. Если родитель передаёт inline-функции и объекты, мемоизация пробивается — сначала стабилизируй ссылки (useCallback/useMemo у родителя), потом memo.
  3. children ломают memo. <MemoCard><Icon /></MemoCard>children это новый элемент на каждом рендере родителя → сравнение всегда «не равно». Мемоизируй children отдельно или принимай данные пропсами, а не разметкой.

Где memo реально окупается: большие статичные поддеревья внутри часто обновляемого родителя (сайдбар рядом с поиском), тяжёлые строки списков, графики. Где нет: листовые элементы, компоненты с дешёвым рендером.

Как понять, есть ли выигрыш? React DevTools Profiler из прошлой главы. Записываешь сценарий (ввод в поиск), смотришь, сколько времени уходит на рендер поддерева. Затем добавляешь мемоизацию и записываешь снова. Числа решают: если рендер Child занимал 0.3 мс — мемоизация не нужна была; если 30 мс на списке из 500 элементов — нужна.

Есть и более низкоуровневый инструмент — performance.now() вокруг вычисления в dev-режиме. Грубо, но для «пара миллисекунд или двести» сгодится.

useRef возвращает объект { current: T }, который:

  1. Стабилен между рендерами — тот же самый объект каждый раз.
  2. Мутация .current не вызывает ререндер.
  3. Живёт, пока компонент смонтирован — переживает рендеры, теряется при размонтировании.

Из этих трёх свойств вытекают два применения.

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, который всегда видит свежий query
const queryRef = useRef(query);
queryRef.current = query; // обновляем каждый рендер — рендер чистый, присваивание ссылке — норм
const stableSearch = useCallback(() => {
fetch(`/api/search?q=${queryRef.current}`); // читает актуальное значение
}, []); // пустые зависимости — но без рассинхрона

Иногда нужно не хранить узел, а выполнить код при его появлении/исчезновении (измерить размеры, подключить стороннюю библиотеку). Атрибут ref принимает и функцию:

// Callback-форма: вызывается с DOM-узлом при монтировании и с null при размонтировании
<div
ref={(node) => {
if (node) {
// узел появился: измеряем, подписываем плагин
console.log(node.getBoundingClientRect());
}
}}
/>

Полезный трюк: callback-форма с useCallback и пустыми зависимостями даёт стабильную ref-функцию — React не будет вызывать её заново с null→node на каждый рендер (что случается с inline-функциями и портит, например, измерения).

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' });

Почему это мощнее пары сеттеров:

  1. Все переходы явные. По dispatch({ type: 'fetch/error' }) видно, что случится со состоянием, — в отличие от трёх разрозненных setStatus, setData, setError, которые могут вызываться в разном порядке и оставлять рассинхрон («loading и error одновременно»).
  2. Редьюсер — чистая функция. Тестируется без React: (state, action) => state. Юнит-тест на каждый кейс — три строчки.
  3. Состояние согласовано по построению. Невозможно забыть сбросить error при новом запросе — редьюсер это делает атомарно.

Паттерн, предшествующий 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.

  1. Мемоизация всего подряд «для скорости». Десять useCallback на компонент — читаемость в минус, выигрыша ноль. Мемоизируй измеренное и то, что уходит вниз по дереву.
  2. Свежие значения в старом callback. useCallback с пустыми зависимостями, читающий стейт — получит его снапшот на момент создания. Лечится добавлением зависимостей (и пересозданием callback’а) или чтением через ref.
  3. Хранение данных рендера в ref. Значение, от которого зависит JSX, в ref не клади: мутация не вызовет ререндер, и интерфейс покажет устаревшее. Это ловушка для тех, кто «не хочет лишних рендеров».
  4. Забытый cleanup для таймеров в ref. Ref не освобождает ресурсы сам — очистка по-прежнему в useEffect cleanup.
  5. Reducer с мутацией. state.items.push(x); return state — React увидит ту же ссылку и проигнорирует обновление. Редьюсер обязан возвращать новый объект (или использовать Immer).
  6. 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 пересоздаётся каждый рендер — эффект тоже.

  1. Поймай пробитую мемоизацию. Собери Parent → (memo)Child. Передай в Child inline-функцию и inline-объект. Проверь через Profiler, что Child рендерится на каждый символ в Parent. Стабилизируй ссылки через useCallback/useMemo — дети затихли. Объясни, почему.
  2. Ref-таймер с cleanup. Реализуй секундомер: кнопки «старт/стоп/сброс», интервал в useRef, очистка в эффекте. Проверь под StrictMode отсутствие утечек интервалов.
  3. useDebounce без библиотеки. Напиши хук useDebouncedValue(value, delay) на useRef (таймер) + useEffect (cleanup). Используй для поиска: запрос уходит через 300 мс после остановки ввода.
  4. Машина состояний загрузки. Перепиши компонент с тремя useState (status/data/error) на useReducer. Добавь кейс «refetch» (сброс error, сохранение старых data). Напиши юнит-тест на редьюсер без рендеринга React.
  5. Callback-ref для измерений. Компонент-аккордеон: при раскрытии измерь высоту содержимого через callback-форма ref и задай её inline-style для анимации. Объясни, почему inline-функция в ref пересоздаётся и как это обойти.