Коротка відповідь. Оптимізація React починається не з useMemo, а з вимірювання. Більшість «гальм» – це не повільний рендер, а зайві ре-рендери через невдалу структуру стану. Порядок дій: виміряй у Profiler → виправ структуру стану й композицію → і лише потім точково мемоізуй те, що справді дороге. Нижче – як це робити крок за кроком.
Чому «обгорнути все в useMemo» не працює
На співбесідах я регулярно чую відповідь «якщо повільно – додам useMemo і React.memo». Це карго-культ: інструмент застосовують за формою, без розуміння причини.
Сама мемоізація не безкоштовна: React зберігає попередні значення, порівнює залежності на кожен рендер, а код стає важчим для читання. Якщо обчислення дешеве, а залежності змінюються щоразу – ти платиш за порівняння і нічого не заощаджуєш. Гірше: useMemo на кожному кроці маскує справжню проблему, через яку компонент взагалі рендериться занадто часто.
Крок 1 – виміряй у React DevTools Profiler
Перш ніж щось міняти, отримай факти. У React DevTools є вкладка Profiler:
- Натисни запис, виконай повільну дію (введення в пошук, відкриття списку), зупини запис.
- Подивись на commits – кожен стовпчик це одне застосування змін до DOM. Багато довгих commits підряд під час набору тексту – типовий симптом.
- У flame-графі видно, які компоненти рендерилися і скільки часу зайняв кожен. Сірі – не рендерилися взагалі.
- Увімкни в налаштуваннях «Record why each component rendered» – Profiler покаже причину: змінилися props, state чи context.
Після цього в тебе не відчуття «щось повільно», а конкретний список: хто рендериться, як часто і чому. Оптимізувати без цього списку – стріляти в темряві.
Звідки насправді беруться зайві ре-рендери
Три причини покривають майже всі випадки:
- Рендер батька = рендер дітей. За замовчуванням React рендерить усе піддерево компонента, чий стан змінився. Якщо стан пошукового поля живе у корені сторінки – кожна літера перерендерює всю сторінку.
- Нові об'єкти в пропсах на кожен рендер.
style={{ color: 'red' }},onClick={() => ...},items={data.filter(...)}– це щоразу нові референси. Для звичайних компонентів це не проблема (вони й так рендеряться разом із батьком), але це зводить нанівецьReact.memoу дітей. - Широкий контекст. Один великий context із десятком полів перерендерює всіх споживачів при зміні будь-якого поля.
Зверни увагу: жодна з причин не лікується «додати useMemo всюди». Перша лікується структурою стану, друга – точковою стабілізацією референсів, третя – розбиттям контексту.
Структура стану важливіша за мемоізацію
Найефективніша «оптимізація» в React – тримати стан якомога ближче до місця використання. Класичний приклад: пошукове поле над великим списком.
До – стан у корені, кожна літера рендерить усе:
function Page({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<HeavyList items={items} query={query} />
<Sidebar /> {/* рендериться на кожну літеру без потреби */}
<Footer /> {/* теж */}
</>
);
}Після – стан спущено у компонент, якому він потрібен:
function Page({ items }: { items: Item[] }) {
return (
<>
<SearchableList items={items} /> {/* стан query живе тут */}
<Sidebar />
<Footer />
</>
);
}Тепер набір тексту рендерить лише SearchableList. Нуль мемоізації – а ефект більший, ніж від React.memo на кожному компоненті.
Другий прийом – композиція через children. Якщо компоненту зі станом потрібно обгортати важкий вміст, передай вміст як children: React не перерендерює елементи, створені поза компонентом, чий стан змінився:
function Collapsible({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>Розгорнути</button>
{open && children} {/* children створені батьком – не рендеряться повторно */}
</div>
);
}Детальніше про те, що саме React порівнює між рендерами, – у статті про reconciliation і keys.
Коли useMemo і memo справді потрібні
Після виправлення структури залишаються два чесні сценарії:
1. Справді дороге обчислення. Фільтрація чи агрегація великого масиву на кожен рендер:
const visibleRows = useMemo(
() => rows.filter(matchesFilters).sort(byColumn(sortKey)),
[rows, matchesFilters, sortKey]
);Критерій «дорого» – не інтуїція, а Profiler: якщо обчислення займає одиниці мілісекунд на реальних даних, мемоізація не потрібна.
2. Стабільні пропси для memo-компонента. Якщо важкий дочірній компонент обгорнуто в React.memo, усі його пропси мають бути стабільними, інакше memo не спрацює жодного разу:
const handleSelect = useCallback((id: string) => setSelected(id), []);
return <HeavyTable rows={visibleRows} onSelect={handleSelect} />;Правило пари: React.memo без стабілізованих пропсів – мертвий вантаж; useCallback без memo-споживача – теж. Вони працюють лише разом.
Карго-культ: як виглядає безглузда мемоізація
// Порівняння залежностей коштує більше, ніж сама конкатенація
const fullName = useMemo(() => `${first} ${last}`, [first, last]);
// useCallback без жодного memo-споживача нижче
const onClick = useCallback(() => setOpen(true), []);
<PlainButton onClick={onClick} />Якщо на співбесіді тебе спитають «навіщо тут useMemo», а відповідь буде «про всяк випадок» – це мінус, а не плюс. Уміння пояснити, чому мемоізації немає, цінується не менше: про це є окремий розбір у гайді з підготовки до React-співбесіди.
Довгі списки: коли структура вже не рятує
Окремий клас проблем – списки на сотні й тисячі рядків. Тут навіть ідеально мемоізований рядок не допоможе: браузер фізично тримає в DOM тисячі вузлів, і перший рендер повільний незалежно від React.
Рішення – віртуалізація: рендерити лише видимі рядки плюс невеликий буфер. Це десятки елементів у DOM замість тисяч. На співбесіді достатньо пояснити принцип (контейнер із фіксованою висотою, абсолютне позиціювання видимого вікна, перерахунок на скрол) і чесно сказати, що в продакшні береш готову бібліотеку, а не пишеш власну.
І суміжний нюанс, який часто перевіряють: некоректні keys у списках змушують React перестворювати DOM-вузли замість повторного використання. key={index} при вставці на початок списку – класична причина «повільного списку», яку не видно у flame-графі напряму. Це тема окремої статті про reconciliation і keys.
Три питання про продуктивність, які ставлять на співбесідах
– Чому React.memo не допоміг?
Найчастіше тому, що хоча б один проп – нестабільний референс: інлайн-функція, новий об'єкт або масив із filter/map у рендері. memo порівнює пропси поверхнево; один новий референс – і порівняння програно.
– Чим useMemo відрізняється від useCallback?
useMemo кешує результат виклику функції, useCallback – саму функцію. useCallback(fn, deps) еквівалентний useMemo(() => fn, deps). Обидва мають сенс лише коли стабільність значення хтось використовує: залежності іншого хука або memo-компонент.
– З чого почнеш, якщо «сторінка гальмує»? Сильна відповідь завжди починається з вимірювання, а не з інструмента: Profiler → знайти найчастіші/найдовші commits → з'ясувати причину рендерів → виправити структуру → точкова мемоізація → повторне вимірювання.
Чекліст діагностики продуктивності
- Виміряй у Profiler: які компоненти рендеряться і чому.
- Чи можна спустити стан нижче або передати важкий вміст через children?
- Чи не створюєш нові об'єкти/масиви/функції у пропсах memo-компонентів?
- Чи не занадто широкий context? Розбий за призначенням.
- Для довгих списків – чи потрібна віртуалізація (рендер лише видимих рядків)?
- Лише тепер: точковий
useMemoдля дорогих обчислень,memo+ стабільні пропси для важких піддерев. - Повтори вимірювання і порівняй commits до/після. Без «після» оптимізація не вважається зробленою.
Уміння пройти цей чекліст уголос – одна з найсильніших відповідей на архітектурному раунді: як саме будувати таку аргументацію, розбираю в статті про React-архітектуру на Senior-співбесіді.