Коротка відповідь. Оптимізація React починається не з useMemo, а з вимірювання. Більшість «гальм» – це не повільний рендер, а зайві ре-рендери через невдалу структуру стану. Порядок дій: виміряй у Profiler → виправ структуру стану й композицію → і лише потім точково мемоізуй те, що справді дороге. Нижче – як це робити крок за кроком.

Чому «обгорнути все в useMemo» не працює

На співбесідах я регулярно чую відповідь «якщо повільно – додам useMemo і React.memo». Це карго-культ: інструмент застосовують за формою, без розуміння причини.

Сама мемоізація не безкоштовна: React зберігає попередні значення, порівнює залежності на кожен рендер, а код стає важчим для читання. Якщо обчислення дешеве, а залежності змінюються щоразу – ти платиш за порівняння і нічого не заощаджуєш. Гірше: useMemo на кожному кроці маскує справжню проблему, через яку компонент взагалі рендериться занадто часто.

Крок 1 – виміряй у React DevTools Profiler

Перш ніж щось міняти, отримай факти. У React DevTools є вкладка Profiler:

  1. Натисни запис, виконай повільну дію (введення в пошук, відкриття списку), зупини запис.
  2. Подивись на commits – кожен стовпчик це одне застосування змін до DOM. Багато довгих commits підряд під час набору тексту – типовий симптом.
  3. У flame-графі видно, які компоненти рендерилися і скільки часу зайняв кожен. Сірі – не рендерилися взагалі.
  4. Увімкни в налаштуваннях «Record why each component rendered» – Profiler покаже причину: змінилися props, state чи context.

Після цього в тебе не відчуття «щось повільно», а конкретний список: хто рендериться, як часто і чому. Оптимізувати без цього списку – стріляти в темряві.

Звідки насправді беруться зайві ре-рендери

Три причини покривають майже всі випадки:

  1. Рендер батька = рендер дітей. За замовчуванням React рендерить усе піддерево компонента, чий стан змінився. Якщо стан пошукового поля живе у корені сторінки – кожна літера перерендерює всю сторінку.
  2. Нові об'єкти в пропсах на кожен рендер. style={{ color: 'red' }}, onClick={() => ...}, items={data.filter(...)} – це щоразу нові референси. Для звичайних компонентів це не проблема (вони й так рендеряться разом із батьком), але це зводить нанівець React.memo у дітей.
  3. Широкий контекст. Один великий 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 → з'ясувати причину рендерів → виправити структуру → точкова мемоізація → повторне вимірювання.

Чекліст діагностики продуктивності

  1. Виміряй у Profiler: які компоненти рендеряться і чому.
  2. Чи можна спустити стан нижче або передати важкий вміст через children?
  3. Чи не створюєш нові об'єкти/масиви/функції у пропсах memo-компонентів?
  4. Чи не занадто широкий context? Розбий за призначенням.
  5. Для довгих списків – чи потрібна віртуалізація (рендер лише видимих рядків)?
  6. Лише тепер: точковий useMemo для дорогих обчислень, memo + стабільні пропси для важких піддерев.
  7. Повтори вимірювання і порівняй commits до/після. Без «після» оптимізація не вважається зробленою.

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