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

memo, useMemo, useCallback

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

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

Ты профилировал медленный список, обернул строку в React.memo — ничего не изменилось. Тогда ты добавил useCallback на обработчик клика — всё ещё ничего. Ты присыпал useMemo на производный массив — флеймграф не дрогнул. Каждый инструмент делает ровно то, что написано в его документации, а страница по-прежнему перерисовывает всё. Инструменты не сломаны. Ты применил их как три независимых заклинания вместо одного механизма.

memo, useMemo и useCallback — не взаимозаменяемая пыль производительности. Это три части одного контракта: memo решает, пропустить ли дочерний компонент, сравнивая пропсы по ссылке; useMemo и useCallback — это то, как ты держишь эти ссылки стабильными между рендерами. Убери любое одно из цепи — и два других не делают ничего. Этот урок — про то, как замкнуть всю петлю, и про режимы отказа, где одно отсутствующее звено молча обнуляет кэш.

Цель

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

1

React.memo пропускает перерисовку дочернего компонента только когда его следующие пропсы поверхностно равны предыдущим. По умолчанию дочерний перерисовывается всякий раз, когда перерисовывается родитель, независимо от пропсов. memo меняет это: перед перерисовкой React поверхностно сравнивает каждый пропс через Object.is. Примитивы сравниваются по значению, поэтому пропс типа number или string, который не изменился, проходит. Но объекты, массивы и функции сравниваются по ссылке — свежий литерал на каждый рендер никогда не равен по Object.is прошлому, так что сравнение проваливается и дочерний всё равно рендерится.

const Row = React.memo(function Row({ user, onSelect }: {
  user: User;
  onSelect: (id: string) => void;
}) {
  return <li onClick={() => onSelect(user.id)}>{user.name}</li>;
});
// обещание memo: пропустить перерисовку, ЕСЛИ user И onSelect стабильны по ссылке

Так что memo необходим, но недостаточен. Он покупает тебе пропуск, только когда каждый непримитивный пропс приходит со стабильной идентичностью. Следующие два инструмента — то, как ты эту стабильность поставляешь.

2

useMemo кэширует идентичность вычисленного значения; useCallback кэширует идентичность функции — оба по массиву зависимостей. Это сторона предложения в контракте. useMemo(fn, deps) запускает fn и возвращает ту же ссылку, пока не изменится dep. useCallback(fn, deps) возвращает ту же ссылку на функцию, пока не изменится dep — это буквально useMemo(() => fn, deps). Их смысл почти никогда не в скорости самого вычисления; он в ссылочной стабильности, чтобы нижестоящий memo (или проверка зависимостей эффекта) увидел «то же значение, пропускаю».

function UserList({ users, onSelect }: Props) {
  // стабильная идентичность → memo-<Row onSelect> может пропустить
  const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);
  // стабильная идентичность → memo-потребитель `visible` может пропустить
  const visible = useMemo(() => users.filter((u) => u.active), [users]);
  return visible.map((u) => <Row key={u.id} user={u} onSelect={handleSelect} />);
}

Если написать const handleSelect = (id) => onSelect(id), ты чеканишь новую функцию на каждый рендер, и любой нижестоящий memo, сравнивающий этот пропс, проваливается каждый раз. Кэш стабилен ровно настолько, насколько стабильны ссылки, которые ты в него подаёшь.

3

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

// ❌ разорванная цепь: стабильный обработчик, но Row НЕ обёрнут в memo → рендерится всё равно
const handleSelect = useCallback(fn, [fn]);
return <PlainRow onSelect={handleSelect} />; // useCallback здесь буквально ничего не делает

// ❌ разорванная цепь: Row обёрнут в memo, но обработчик — свежий литерал → никогда не пропускает
return <MemoRow onSelect={(id) => onSelect(id)} />;

// ✅ замкнутая цепь: memo-дочерний + стабильный пропс → Row пропускает, пока user/onSelect не менялись
return <MemoRow user={user} onSelect={handleSelect} />;

Это senior-переформулировка: ты не «добавляешь useCallback». Ты либо замыкаешь цепь мемоизации, либо её у тебя нет.

4

Два режима отказа: мемоизация без memo-потребителя и неправильный массив зависимостей. Первый — чистая трата: useCallback или useMemo, чей результат течёт только в обычный (не-memo) компонент, в интринсик-элемент вроде <button onClick={...}> или вообще никуда, не имеющего отношения к рендеру. Он стоит аллокации и проверки зависимостей на каждый рендер и пропускает ноль рендеров. Второй — тоньше и хуже: массив зависимостей, опускающий значение, которое читает замыкание, даёт тебе устаревшее кэшированное значение (колбэк, вызывающий onSelect прошлого рендера), тогда как массив с нестабильным значением (инлайн-объект, значение, меняющееся каждый рендер) инвалидирует кэш на каждый рендер, так что ты платишь всю цену мемоизации и не получаешь никакой выгоды.

// ❌ устаревание: обработчик замыкается на `query`, но deps его опускают → стреляет старым запросом
const search = useCallback(() => fetchResults(query), []); // баг: пропущен `query`

// ❌ самоподрыв: `options` — новый объект на каждый рендер → useMemo пересчитывает всегда
const opts = { sort: "name" };
const sorted = useMemo(() => sortBy(users, opts), [users, opts]); // opts ломает это

Линт-правило react-hooks/exhaustive-deps ловит случай устаревания. Случай не-memo-потребителя за тебя не ловит ничто — это отказ рассуждения, а не синтаксиса, и потому «мемоизация — это система» должна стать привычкой, а не пунктом чек-листа.

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

Поле поиска, перерисовывающее список из 5000 строк на каждое нажатие клавиши, — починено замыканием цепи. Родитель владеет запросом; набор текста перерисовывает родителя, который перерисовывает каждую строку.

До — три попытки, ни одна не работает, потому что цепь так и не замкнута:

function People({ users, onSelect }: Props) {
  const [q, setQ] = useState("");
  const visible = users.filter((u) => u.name.includes(q)); // новый массив на каждый рендер
  return (
    <>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      {visible.map((u) => (
        // Row НЕ обёрнут в memo, а onSelect — свежее замыкание → каждое нажатие
        // перерисовывает все 5000 строк, хотя изменился только `q`
        <Row key={u.id} user={u} onSelect={(id) => onSelect(id)} />
      ))}
    </>
  );
}
function Row({ user, onSelect }: RowProps) { /* дорого */ }

После — каждое звено на месте, так что набор перерисовывает только строки, чья видимость реально изменилась:

const Row = React.memo(function Row({ user, onSelect }: RowProps) {
  return <li onClick={() => onSelect(user.id)}>{user.name}</li>;
});

function People({ users, onSelect }: Props) {
  const [q, setQ] = useState("");
  // стабильная идентичность обработчика (зависит только от onSelect родителя)
  const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);
  // стабильный производный массив (пересчитывается только при изменении users или q)
  const visible = useMemo(
    () => users.filter((u) => u.name.includes(q)),
    [users, q],
  );
  return (
    <>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      {visible.map((u) => (
        <Row key={u.id} user={u} onSelect={handleSelect} />
      ))}
    </>
  );
}

Теперь Row обёрнут в memo (решающий), handleSelect стабилен между нажатиями (useCallback), а visible стабилен, пока держатся users/q (useMemo). Каждая строка получает тот же user и ту же ссылку onSelect, так что memo её пропускает. Перерисовываются только строки, чей объект user реально отличается. Убери любое из трёх — сними memo с Row, заинлайнь обработчик или добавь нестабильную зависимость к visible — и ты снова при 5000 потраченных впустую рендерах. Починкой был не инструмент; это была замкнутая петля.

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

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

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

Самая частая трата — useCallback, обёрнутый вокруг обработчика, который передаётся прямо в DOM-элемент: <button onClick={useCallback(...)}>. Интринсик-элементы не memo-компоненты; они не пропускаются по идентичности пропсов, так что стабильная ссылка ничего не покупает, а useCallback — чистый оверхед. useCallback/useMemo отрабатывают своё содержание, только когда значение переходит в React.memo-дочерний или питает массив зависимостей другого хука (useEffect, другой useMemo). Если потребитель не один из них — удали обёртку: немемоизированная версия быстрее.

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

Родитель оборачивает обработчик в useCallback и передаёт его в <ChildRow onSelect={handler} />. ChildRow — обычный функциональный компонент (не обёрнут в React.memo). Родитель перерисовывается часто. Что useCallback даёт здесь для производительности рендеринга?

Итог

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

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.