Skip to main content

Як працює useMemo і навіщо він потрібен

useMemo - це хук React, який кешує обчислене значення між рендерами і перераховує його лише тоді, коли змінюються вказані залежності.

Теорія

TL;DR

  • Уяви кухаря, який готує дорогий соус один раз за зміну і використовує його далі, поки ключові інгредієнти не змінились.
  • Без useMemo кожен рендер запускає обчислення знову, навіть якщо вхідні дані ті самі.
  • React зберігає результат і порівнює залежності через Object.is. Якщо нічого не змінилось, повертає закешоване значення.
  • Використовуй, коли обчислення займає більше ~5ms і залежності стабільні більшість рендерів.
  • Функції - useCallback, значення - useMemo.

Швидкий приклад

jsx
import { useMemo, useState } from 'react'; const TodoList = ({ todos }) => { const [filter, setFilter] = useState('all'); // Без useMemo: фільтрація на КОЖНОМУ рендері, навіть якщо filter не змінився // const visible = todos.filter(t => t.status === filter); const visible = useMemo( () => todos.filter(t => t.status === filter), [todos, filter] // перераховуємо тільки при зміні цих значень ); return <ul>{visible.map(t => <li key={t.id}>{t.text}</li>)}</ul>; };

Якщо батьківський компонент рендериться повторно, але filter і todos не змінились, useMemo повертає закешований масив. Фільтрація не відбувається.

Головна різниця

Без useMemo важкі функції запускаються на кожному рендері незалежно від того, чи змінились вхідні дані. Але є ще одна проблема: кожен рендер створює нове посилання на об'єкт або масив, що ламає рівність за посиланням для дочірніх компонентів і запускає їхні рендери теж. useMemo зберігає попередній результат у вузлі fiber компонента, порівнює кожну залежність через Object.is і пропускає callback, якщо нічого не змінилось. Один виклик рятує і від зайвих обчислень, і від каскадних рендерів нижче по дереву.

Коли використовувати

  • Важка фільтрація, сортування або reduce по великих масивах (1 000+ елементів) - useMemo.
  • Похідний стан, який залишається стабільним більшість рендерів - useMemo.
  • Об'єкт або масив, що передається як проп до компонента з React.memo - useMemo.
  • Обчислення менше 1ms - просто напиши вираз inline.
  • Залежності змінюються на кожному рендері - useMemo тут не допоможе, кеш ніколи не спрацює.

Як це працює всередині

React зберігає кожен результат useMemo у зв'язаному списку memoizedState на вузлі fiber компонента. При першому рендері запускається mountMemo - зберігаються і значення, і масив залежностей. При повторному рендері запускається updateMemo: React проходить по залежностях через Object.is (це функція areHookInputsEqual у вихідному коді React) і якщо всі збігаються, повертає закешоване значення без виклику callback. Якщо хоча б одна залежність змінилась, callback запускається і результат зберігається.

Два моменти, які варто знати. По-перше, useMemo виконується синхронно під час фази рендеру, тому async-callback всередині не підтримується. По-друге, кеш не постійний. React може його видалити при нестачі пам'яті або коли fiber відкидається при розмонтуванні. Це підказка для оптимізації, а не гарантоване сховище.

useMemo vs useCallback

useMemouseCallback
ПовертаєЗначенняФункцію
СценарійОбчислені дані, об'єкти, масивиОбробники подій, callbacks
ПрикладuseMemo(() => a + b, [a, b])useCallback(() => doX(), [x])
Коли пропуститиДешеве обчисленняCallback не передається дочірнім компонентам

Типові помилки

Нестабільний об'єкт у залежностях

jsx
// Перераховує кожен рендер - { key: 'value' } це нове посилання щоразу const value = useMemo(() => heavyCalc(items), [items, { key: 'value' }]); // Виправлення: передай примітив напряму const value = useMemo(() => heavyCalc(items), [items, config.key]);

Мемоізація дешевих операцій

jsx
// Накладні витрати хука більші за економію const doubled = useMemo(() => a * 2, [a]); // Просто напиши inline const doubled = a * 2;

Мутація мемоізованого значення

jsx
const list = useMemo(() => items.filter(isActive), [items]); list.push(newItem); // Псує кеш - React очікує, що callback чистий // Виправлення: створи новий масив const withNew = [...list, newItem];

Відсутні залежності

jsx
// userId змінюється, але useMemo ніколи не перераховує через порожній deps const result = useMemo(() => fetchData(userId), []); // Виправлення: додай userId до залежностей const result = useMemo(() => fetchData(userId), [userId]);

Каскад вкладених useMemo

jsx
const data1 = useMemo(() => heavy1(input), [input]); const data2 = useMemo(() => heavy2(data1), [data1]); // data1 змінився - data2 теж перераховується // Виправлення: об'єднай в один useMemo для всього пайплайну const { data1, data2 } = useMemo(() => { const d1 = heavy1(input); return { data1: d1, data2: heavy2(d1) }; }, [input]);

В продакшені я бачив, як такий каскадний патерн знищував продуктивність дашбордів із 4-5 похідними станами ланцюжком. Об'єднання в один useMemo скорочувало час рендеру на 60%.

Де зустрічається в реальних проектах

  • TanStack Query мемоізує результати запитів, щоб стабільні дані не запускали зайві рендери нижче по дереву.
  • Redux Toolkit's createSelector із бібліотеки Reselect реалізує ту саму ідею, що й useMemo, але на рівні стору.
  • Next.js використовує мемоізовані похідні метадані при обробці getStaticProps.
  • React.memo + useMemo - стандартний підхід: React.memo пропускає рендер дочірнього компонента, а useMemo тримає пропси стабільними за посиланням, щоб пропуск реально спрацьовував.

Питання на співбесіді

Q: Яка часова складність порівняння залежностей у useMemo?
A: O(n), де n - кількість залежностей. React проходить по кожній через Object.is. Тому масив залежностей краще тримати коротким, до 5 елементів.

Q: Чи блокує useMemo рендеринг?
A: Так. Callback виконується синхронно під час фази рендеру. Якщо обчислення справді важке і блокує UI, useMemo не вирішить проблему - треба виносити роботу з головного потоку.

Q: У чому різниця між useMemo і React.memo?
A: useMemo кешує значення всередині одного компонента. React.memo обгортає компонент і пропускає його рендер, якщо пропси не змінились. Вони вирішують різні проблеми, але добре доповнюють одне одного.

Q: Що трапляється, якщо залежність - це об'єкт Date?
A: Object.is порівнює за посиланням. new Date() на кожному рендері - це новий об'єкт, тому порівняння завжди провалюється і useMemo завжди перераховує. Використовуй примітив - числову мітку часу або ISO-рядок.

Q: (Senior) Як concurrent mode впливає на кеш useMemo?
A: У React 18 низькопріоритетна робота може бути перервана, а fiber відкинутий. Коли fiber відкидається, його memoizedState втрачається і при перезапуску useMemo обчислює з нуля. Для некритичних обчислень обгорни оновлення стану в startTransition, щоб React знав, що може відкласти цю роботу.

Приклади

Базовий: фільтрація списку товарів

jsx
import { useMemo, useState } from 'react'; const ProductList = ({ products }) => { const [search, setSearch] = useState(''); const filtered = useMemo( () => products.filter(p => p.name.toLowerCase().includes(search.toLowerCase())), [products, search] ); return ( <> <input value={search} onChange={e => setSearch(e.target.value)} /> <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul> </> ); };

filtered перераховується тільки при зміні products або search. Введення тексту в input запускає перерахунок. Рендер батьківського компонента з тими самими пропсами - ні.

Середній: обчислення статистики в todo-додатку

jsx
const TodoStats = ({ todos }) => { const { activeCount, completedCount } = useMemo(() => { let active = 0; let completed = 0; todos.forEach(todo => { if (todo.completed) completed++; else active++; }); return { activeCount: active, completedCount: completed }; }, [todos]); return <p>{activeCount} активних, {completedCount} завершених</p>; };

Об'єкт, повернутий із useMemo, залишається тим самим посиланням між рендерами, поки todos не змінюється. Це важливо, якщо TodoStats обгорнутий у React.memo - без цього новий об'єкт щоразу ламав би пропуск рендеру.

Просунутий: нестабільний об'єкт у залежностях

jsx
// Це не працює - config це новий об'єкт на кожному рендері батька const BadChart = ({ data, config }) => { const processed = useMemo( () => data.map(item => ({ ...item, color: config.theme })), [data, config] // config !== prev config, навіть якщо theme не змінився ); return <Chart data={processed} />; }; // Виправлення - деструктуруй примітив, який реально потрібен const GoodChart = ({ data, config }) => { const { theme } = config; const processed = useMemo( () => data.map(item => ({ ...item, color: theme })), [data, theme] // порівняння примітивів працює коректно ); return <Chart data={processed} />; };

Цей edge case спотикає навіть досвідчених розробників. Лінтер не завжди його ловить, бо config технічно використовується всередині callback. Виправлення - деструктурувати на початку і передавати до масиву залежностей тільки примітиви.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?