Skip to main content

Техніки оптимізації продуктивності React

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

Теорія

TL;DR

  • React ре-рендерить кожен дочірній компонент, коли змінюється стан батька; оптимізація додає ворота порівняння, щоб пропускати незмінені
  • React.memo кешує результат рендеру компонента; useMemo кешує обчислене значення; useCallback кешує посилання на функцію
  • Спочатку профілюй через React DevTools Profiler. Колокація стану і розбиття коду зазвичай дають більший ефект, ніж memo скрізь
  • Список з 200+ елементів → віртуалізація. Бандл більше 500KB → React.lazy. Обробник викликає ре-рендери дитини → useCallback + React.memo

Короткий приклад

tsx
// Без оптимізації: Child ре-рендериться при кожній зміні count function Parent() { const [count, setCount] = useState(0); return <Child name="Item" count={count} />; } // З React.memo: Child пропускає ре-рендер, якщо props не змінились (поверхневе порівняння) const Child = React.memo(({ name, count }: { name: string; count: number }) => { console.log(`${name} rendered`); // Виводить тільки при реальній зміні props return <div>{name}: {count}</div>; });

Parent ре-рендериться при кожному кліку. Мемоізований Child виводить лог тільки коли name або count реально змінюються. Без React.memo - при кожному рендері.

Чому React ре-рендерить за замовчуванням

Алгоритм узгодження (reconciliation) React ре-рендерить все піддерево під компонентом, чий стан або props змінились. Для маленьких дерев це непомітно. Для дашборду з 50+ дочірніми компонентами - накопичується швидко.

Техніки оптимізації не змінюють алгоритм. Вони додають ворота з поверхневим порівнянням перед запуском функції компонента. Якщо props збігаються - React не торкається JSX і DOM.

Коли використовувати кожну техніку

  • Батько ре-рендериться часто, props дитини стабільніReact.memo
  • Важкі похідні дані: reduce, map, sortuseMemo
  • Обробник передається як prop у мемоізовану дитинуuseCallback
  • Список з 200+ елементівreact-window або @tanstack/react-virtual
  • Початковий бандл більше 500KBReact.lazy + Suspense
  • Зміна стану ре-рендерить далекі частини дерева → колокація стану

Таблиця порівняння

ТехнікаЩо кешуєКоли перезапускаєтьсяВплив на бандлДоступна з
React.memoВесь рендер компонентаЗміна props (поверхнево)НемаReact 16.6
useMemoБудь-яке обчислене значенняЗміна масиву залежностейНемаReact 16.8
useCallbackПосилання на функціюЗміна масиву залежностейНемаReact 16.8
React.lazyЧанк кодуПерший import()Зменшує початковийReact 16.6
react-windowВидимі елементи спискуСкрол / viewport~10KBБібліотека

useMemo і useCallback - один механізм. useCallback(fn, deps) - скорочення для useMemo(() => fn, deps).

Як React виконує мемоізацію всередині

Fiber-архітектура React пакетує оновлення стану через планувальник (scheduler) на основі requestIdleCallback. На фазі commit мемоізовані компоненти проходять перевірку, схожу на shouldComponentUpdate. Всередині - поверхневий diff через функцію areEqual. Якщо всі props збігаються - React пропускає JSX-обхід і зміни DOM повністю.

V8 теж виграє від цього. Стабільні посилання на функції з useCallback дозволяють рушію повторно використовувати вже скомпільовані замикання (closure) замість виділення нових при кожному рендері.

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

Помилка 1: Відсутні залежності в useMemo або useCallback

tsx
// Застарілі дані: items змінюється, але doubled ніколи не оновлюється const doubled = useMemo(() => items.map(i => i * 2), []); // Виправлення: додати залежність const doubled = useMemo(() => items.map(i => i * 2), [items]);

ESLint-правило react-hooks/exhaustive-deps ловить це автоматично. Це найпоширеніша скарга на r/reactjs щодо хуків.

Помилка 2: Передача нових об'єктів або функцій у мемоізовану дитину

tsx
// Ре-рендер при кожному рендері батька - config щоразу новий об'єкт const Child = React.memo(({ config }) => <div>{config.theme}</div>); <Child config={{ theme: "dark", size: 14 }} /> // ❌ // Виправлення: мемоізуй об'єкт або передавай примітиви const config = useMemo(() => ({ theme: "dark", size: 14 }), []); <Child config={config} /> // ✅

Поверхневе порівняння перевіряє посилання, не вміст. Новий об'єктний літерал не проходить перевірку, навіть якщо всередині нічого не змінилось. Саме це стоїть за більшістю питань на StackOverflow про React.memo "який не працює".

Помилка 3: useCallback без мемоізації дитини

tsx
// handleClick стабільний, але Child все одно ре-рендериться const handleClick = useCallback(() => console.log("click"), []); <Child onClick={handleClick} /> // Child без React.memo

useCallback сам по собі нічого не дає для запобігання ре-рендерам. Дитина теж потребує React.memo, інакше стабільне посилання просто ігнорується.

Помилка 4: Мемоізація всього підряд

Додавання React.memo до кожного компонента збільшує накладні витрати порівняння при кожному рендері. Для компонентів, що рендеряться рідко або отримують прості примітиви, ці витрати можуть перевищити економію. Дані з профайлера у блозі Дена Абрамова показують +15% CPU від надмірної мемоізації. Застосовуй memo тільки там, де DevTools Profiler показує реальну проблему.

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

  • Next.js: React.lazy / Suspense для code splitting на рівні маршрутів; /dashboard завантажується тільки при першому відвідуванні
  • Redux Toolkit: createSelector з Reselect обгортає логіку useMemo для кешування похідного стану
  • Material-UI: React.memo на ListItem для пропуску ре-рендерів у drawer зі 100+ елементами
  • TanStack Query: useQuery автоматично мемоізує дані запиту, тому компоненти ре-рендеряться тільки при реальних змінах
  • Vercel commerce template: useMemo для підрахунку кошика, useCallback для обробників оформлення замовлення

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

Q: Коли React.memo не запобігає ре-рендеру?
A: Коли компонент споживає context, що змінився, або коли батько викликає forceUpdate. Також коли props містять об'єкти або функції, створені inline, бо поверхневе порівняння бачить їх як нові значення кожного разу.

Q: У чому різниця між useMemo і useCallback?
A: useMemo кешує будь-яке значення: об'єкт, масив, результат обчислення. useCallback - скорочення для useMemo(() => fn, deps). Використовуй useCallback коли кешоване значення є функцією, useMemo коли це дані.

Q: Як оптимізувати список з 10,000 елементів?
A: Віртуалізація через react-window або @tanstack/react-virtual. Рендеряться тільки видимі рядки - приблизно 9-15 замість 10,000. Пам'ять скорочується приблизно в 100 разів, скрол залишається на 60fps там, де невіртуалізований список падає до 15fps або нижче.

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

Q: (Senior) Як планувальник взаємодіє з мемоізованими компонентами в Concurrent Mode?
A: Низькопріоритетні оновлення через useTransition можуть бути перервані посередині дерева. Якщо прийде більш пріоритетне оновлення - React може пропустити рендер мемоізованих компонентів. Перевірка memo все одно запускається, але компонент може ре-рендеритись знову, коли планувальник відновить перервану роботу. Виміряти це можна через getCurrentPriorityLevel() з пакету React scheduler.

Приклади

Дашборд з useMemo і useCallback

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

tsx
function SalesDashboard({ salesData }: { salesData: Sale[] }) { // Перераховується тільки при зміні посилання salesData const stats = useMemo(() => { const total = salesData.reduce((sum, s) => sum + s.amount, 0); return { total, avg: total / salesData.length, count: salesData.length, }; }, [salesData]); // Стабільне посилання: StatsPanel пропускає ре-рендер при рендері батька const handleExport = useCallback(() => { exportToCSV(stats); }, [stats]); return ( <div> <StatsPanel stats={stats} onExport={handleExport} /> <SalesTable data={salesData} /> </div> ); }

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

Віртуалізований список з react-window

Рендер 10,000 товарів без підвисань браузера.

tsx
import { FixedSizeList as List } from "react-window"; const VirtualizedList = React.memo(({ items }: { items: Item[] }) => ( <List height={600} // Фіксована висота контейнера в px itemCount={items.length} itemSize={70} // Висота кожного рядка в px width="100%" > {({ index, style }) => ( // style містить абсолютне позиціювання від react-window <div style={style}>{items[index].name}</div> )} </List> ));

З 10,000 елементів по 70px загальна висота прокрутки - 700,000px. React-window рендерить тільки ~9 видимих рядків у будь-який момент. Chrome Profiler показує скрол на 60fps там, де невіртуалізований список падає до 15fps або нижче.

Колокація стану для запобігання ре-рендерам всього дерева

Переміщення стану наведення вниз до компонента, якому він насправді потрібен.

tsx
// ❌ Стан наведення в App: всі діти ре-рендеряться при кожному русі миші function App() { const [hoveredId, setHoveredId] = useState<string | null>(null); return <ProductList items={products} hoveredId={hoveredId} onHover={setHoveredId} />; } // ✅ Стан наведення в самому елементі: ре-рендериться тільки він function ProductCard({ product }: { product: Product }) { const [isHovered, setIsHovered] = useState(false); return ( <div onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className={isHovered ? "highlighted" : ""} > {product.name} </div> ); }

На практиці це часто найбільший виграш. Перед тим як тягнутись до memo, перевір чи стан, що викликає ре-рендери, справді має жити так високо. Я бачив, як дерево з 40 компонентами скорочувало ре-рендери до одиниць просто переміщенням одного useState на два рівні нижче.

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

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

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

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