memo, useMemo, useCallback
memo, useMemo и useCallback окупаются только как одна система: memo пропускает дочерний компонент с поверхностно равными пропсами, а useMemo/useCallback дают стабильные ссылки на объекты и функции, чтобы это равенство держалось. Нет одного звена — вся цепь становится пустышкой.
Ты профилировал медленный список, обернул строку в React.memo — ничего не изменилось. Тогда ты добавил useCallback на обработчик клика — всё ещё ничего. Ты присыпал useMemo на производный массив — флеймграф не дрогнул. Каждый инструмент делает ровно то, что написано в его документации, а страница по-прежнему перерисовывает всё. Инструменты не сломаны. Ты применил их как три независимых заклинания вместо одного механизма.
memo, useMemo и useCallback — не взаимозаменяемая пыль производительности. Это три части одного контракта: memo решает, пропустить ли дочерний компонент, сравнивая пропсы по ссылке; useMemo и useCallback — это то, как ты держишь эти ссылки стабильными между рендерами. Убери любое одно из цепи — и два других не делают ничего. Этот урок — про то, как замкнуть всю петлю, и про режимы отказа, где одно отсутствующее звено молча обнуляет кэш.
После этого урока ты можешь точно сформулировать, что делает каждый инструмент — React.memo пропускает перерисовку, когда следующие пропсы поверхностно равны предыдущим, useMemo кэширует идентичность вычисленного значения, useCallback кэширует идентичность функции, — и объяснить, почему memo-дочерний пропускается, только когда его объектные и функциональные пропсы стабильны. Ты можешь диагностировать два характерных отказа: useCallback/useMemo, чей потребитель не обёрнут в memo (нулевой эффект), и устаревший либо нестабильный массив зависимостей, который тихо ломает кэш. И ты можешь решить, когда мемоизация стоит своей цены, а когда это шум.
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 необходим, но недостаточен. Он покупает тебе пропуск, только когда каждый непримитивный пропс приходит со стабильной идентичностью. Следующие два инструмента — то, как ты эту стабильность поставляешь.
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, сравнивающий этот пропс, проваливается каждый раз. Кэш стабилен ровно настолько, насколько стабильны ссылки, которые ты в него подаёшь.
Трио — одна система: 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». Ты либо замыкаешь цепь мемоизации, либо её у тебя нет.
Два режима отказа: мемоизация без 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.