Оптимизация React приложения: полное руководство от А до Я
4 мая 2026 г.
React — это декларативная библиотека, которая сама решает, когда и что перерисовывать. Однако абстракции React не бесплатны: неоптимальный код приводит к лагам, дергающимся интерфейсам и медленной загрузке. Оптимизация React приложения — это системный процесс, который начинается с измерений, а не с предположений. В этом руководстве я разберу все ключевые техники: от базовых до продвинутых, с примерами кода, реальными замерами производительности и типичными ошибками.
Когда вообще нужно оптимизировать React приложение?
Прежде чем оптимизировать, задайте себе вопрос: «Есть ли реальная проблема?» React по умолчанию быстр для 95% сценариев. Многие преждевременные оптимизации только усложняют код и даже замедляют его из-за лишних проверок.
Показатели, которые требуют оптимизации:
- FPS (Frames Per Second) падает ниже 60 при скролле или анимации — интерфейс дергается.
- Time to Interactive (TTI) больше 5 секунд на 3G — пользователь уходит.
- Рендер компонента занимает >50ms по данным Profiler — блокируется main thread.
- Лишние ререндеры — компонент перерисовывается 10+ раз за одно действие.
- First Contentful Paint (FCP) > 1.8 секунд.
Если этих проблем нет — не трогайте код. Если есть — читайте дальше и применяйте точечно.
Диагностика: как измерить производительность до оптимизации
Без измерений вы слепы. Всегда замеряйте до и после. Я использую три основных инструмента.
React DevTools Profiler: пошаговая инструкция
Установите расширение React DevTools для Chrome/Firefox. Откройте вкладку Profiler. Нажмите круглую кнопку записи (Start profiling), совершите действие в приложении (клик, скролл, ввод текста), остановите запись.
Что вы увидите:
- Flamegraph — каждый бар — это рендер компонента. Чем выше бар, тем дольше рендер. Ищите высокие красные/желтые бары.
- Ranked — компоненты отсортированы по времени рендера от самого долгого к самому быстрому. Это главный view для поиска узких мест.
- Interactions — если вы обернули действия в
unstable_trace, можно увидеть, какие обновления вызваны каким действием пользователя.
Совет: Включите «Highlight updates when components render» в настройках React DevTools (вкладка Components, шестерёнка). При взаимодействии с интерфейсом вы увидите цветные подсветки вокруг перерисовывающихся элементов. Зеленые — быстро, жёлтые — средне, красные — долго.
why-did-you-render: находим лишние ререндеры автоматически
React DevTools показывает факт рендера, но не всегда объясняет причину. Библиотека @welldone-software/why-did-you-render логирует в консоль, почему компонент перерендерился.
// Установка: npm install @welldone-software/why-did-you-render
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true,
trackHooks: true,
logOnDifferentValues: true,
});
}
// В компоненте, который хотите отслеживать:
const MyComponent = React.memo(({ name, age }) => {
return <div>{name}, {age}</div>
});
MyComponent.whyDidYouRender = true;
В консоли вы увидите что-то вроде: MyComponent re-rendered because of props changes: age (1 → 2). Это моментально указывает на источник проблемы.
Chrome DevTools Performance: анализ main thread и layout thrashing
Иногда проблема не в React, а в браузере: долгие пересчёты стилей (Recalculate Style), принудительные синхронные лейауты (forced reflow), огромные деревья DOM.
Откройте Chrome DevTools → Performance, нажмите запись, сделайте действие, остановите. Ищите:
- Красные треугольники на таймлайне — предупреждения о долгих задачах (>50ms).
- Розовые полосы Layout/Recalc Style — проблема с DOM или CSS (слишком много элементов или частые изменения классов).
- Жёлтые полосы Scripting длиннее 200ms — слишком тяжёлые JavaScript задачи, возможно, стоит вынести в Web Worker.
Мемоизация: как и когда использовать
Мемоизация — это кеширование результата функции или компонента, чтобы не пересчитывать заново, если входные данные не изменились. Это мощно, но не бесплатно.
React.memo: глубокая настройка сравнения пропсов
По умолчанию React.memo делает поверхностное сравнение пропсов (Object.is для каждого пропа). Если пропс — объект или массив, сравнение будет по ссылке, а не по содержимому. Это ловушка для новичков.
// Проблема: newProps.user === oldProps.user всегда false, если объект создан в рендере
user = { id: 1, name: 'John' } // каждый рендер — новый объект
// Решение 1: вынести объект за пределы компонента
const DEFAULT_USER = { id: 1, name: 'John' };
// Решение 2: кастомная функция сравнения
const MyComponent = React.memo(
({ user, onSave }) => <div>...</div>,
(prevProps, nextProps) => {
return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.onSave === nextProps.onSave // ссылка на функцию должна быть стабильной (useCallback)
);
}
);
Правила использования React.memo:
- Оборачивайте чистые функциональные компоненты, которые рендерятся часто и с одинаковыми пропсами.
- Не оборачивайте компоненты, которые всегда получают разные пропсы (например, зависящие от ввода пользователя) — проверка будет бесполезной тратой времени.
- Комбинируйте с
useCallbackиuseMemoдля стабильности ссылок. - Для классовых компонентов используйте
PureComponent— это аналогReact.memoдля классов.
useMemo: когда кешировать вычисления (с бенчмарками)
useMemo запоминает результат вызова функции между рендерами. Но оно не бесплатно: React хранит предыдущее значение в памяти и сравнивает зависимости.
Когда useMemo реально нужен:
- Вычисления, которые работают за O(n²) или O(n×m) на больших массивах (фильтрация 10 000+ элементов, сложные агрегации, рекурсивные обходы).
- Создание объектов или массивов, которые передаются в
React.memoкак пропсы (чтобы сохранить ссылку). - Подготовка данных для дорогих дочерних компонентов.
Пример с бенчмарком:
// Дорогая функция — сортировка и фильтрация 100 000 записей
function processData(rawData, filterText, sortKey) {
console.time('processData');
const filtered = rawData.filter(item => item.name.includes(filterText));
const sorted = [...filtered].sort((a, b) => a[sortKey] > b[sortKey] ? 1 : -1);
console.timeEnd('processData'); // ~120ms на каждую фильтрацию
return sorted;
}
// Без useMemo — 120ms при изменении unrelatedState (не связанное состояние)
const processed = processData(rawData, filterText, sortKey);
// С useMemo — 120ms только когда filterText/sortKey/rawData меняются
const processed = useMemo(
() => processData(rawData, filterText, sortKey),
[rawData, filterText, sortKey]
);
Антипаттерн: оборачивать в useMemo тривиальные операции (сложение, конкатенацию строк, взятие длины массива). Проверка зависимостей и сохранение в памяти будут дороже, чем сам расчёт.
useCallback: практические паттерны и антипаттерны
useCallback возвращает ту же самую ссылку на функцию между рендерами, если зависимости не изменились. Это нужно исключительно для передачи в компоненты, обёрнутые в React.memo, или в массивы зависимостей хуков (useEffect, useMemo).
Корректное использование:
const Parent = () => {
const [count, setCount] = useState(0);
// Правильно: функция зависит от count, useCallback оправдан
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
return <Child onClick={handleClick} />; // Child обёрнут в React.memo
};
const Child = React.memo(({ onClick }) => {
return <button onClick={onClick}>Click</button>;
});
Когда useCallback не нужен:
- Функция передаётся в обычный (не мемоизированный) компонент — мемоизация не даст выигрыша.
- Функция не передаётся никуда, а просто вызывается внутри компонента.
- Компонент перерендеривается редко (раз в несколько секунд).
Паттерн «подъёма функции»: иногда проще вынести функцию за пределы компонента, чем мемоизировать её.
// Вместо useCallback:
const handleClick = useCallback(() => { doSomething(prop); }, [prop]);
// Вынесите функцию, если она не зависит от пропсов и состояния:
const handleClick = (id) => { store.dispatch(action(id)); };
// Тогда её можно объявить один раз на уровне модуля, и ссылка всегда стабильна.
Цена мемоизации: влияние на память и когда она вредит
Каждый useMemo и useCallback хранит предыдущее значение в памяти. В React 18+ эта память освобождается, когда компонент демонтируется, но во время жизни компонента значения накапливаются.
Когда мемоизация вредит:
- Вы оборачиваете каждый компонент в memo, каждую функцию в useCallback — код становится неподдерживаемым, а выигрыша нет.
- Зависимости меняются при каждом рендере (например, нестабильные объекты или функции из пропсов) — useEffect/useCallback будут пересоздаваться постоянно, и вы получите медленнее, чем без мемоизации.
- Расчёт тривиален (типа
return a + b) — сравнение зависимостей занимает больше времени, чем сам расчёт.
Золотое правило: профилируйте сначала. Если рендер занимает <5ms без мемоизации — не трогайте. Если 15ms с частыми ререндерами — пробуйте. Если 50ms+ — обязательно мемоизируйте.
Code splitting и ленивая загрузка: уменьшаем бандл
Одна из самых эффективных оптимизаций — сократить код, который нужно загрузить при первом посещении. В среднем код сплиттинг уменьшает начальный бандл на 30–60%.
React.lazy и Suspense: динамический импорт компонентов
React.lazy позволяет импортировать компоненты динамически, как обычные модули. В сочетании с Suspense вы показываете fallback во время загрузки.
import { lazy, Suspense } from 'react';
// Обычный импорт — попадёт в основной бандл
// import HeavyReport from './HeavyReport';
// Ленивый импорт — отдельный чанк
const HeavyReport = lazy(() => import('./HeavyReport'));
function Dashboard() {
const [showReport, setShowReport] = useState(false);
return (
<div>
<button onClick={() => setShowReport(true)}>Показать отчёт</button>
{showReport && (
<Suspense fallback=<div>Загрузка отчёта...</div>>
<HeavyReport />
</Suspense>
)}
</div>
);
}
Важно: React.lazy пока не работает с серверным рендерингом (SSR) в React < 18. В Next.js используйте next/dynamic.
Разделение по маршрутам в React Router
Самое простое и выгодное — разделить бандл по маршрутам. Пользователь загружает только тот код, который нужен для текущей страницы.
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home'));
const Products = lazy(() => import('./pages/Products'));
const ProductDetails = lazy(() => import('./pages/ProductDetails'));
const Checkout = lazy(() => import('./pages/Checkout')); // тяжёлая страница с формами и валидацией
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<GlobalSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetails />} />
<Route path="/checkout" element={<Checkout />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
После такой настройки начальный бандл станет размером с одну страницу + роутинг. Остальное подгружается по мере необходимости.
Webpack Bundle Analyzer: визуализируем и сокращаем бандл
Вы не можете оптимизировать то, что не видите. webpack-bundle-analyzer строит интерактивную карту вашего бандла.
npm install --save-dev webpack-bundle-analyzer
// В webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static', // генерирует HTML-файл
openAnalyzer: true,
}),
],
};
Что искать на карте:
- Огромные прямоугольники — библиотеки, которые занимают много места. Проверьте, можно ли их заменить на более лёгкие аналоги (moment → date-fns, lodash → lodash-es с деревом тряски).
- Дублирование библиотек — одна и та же библиотека может попасть в бандл несколько раз из-за разных версий в node_modules. Решение:
resolutionsв package.json или настройка webpack alias. - Целые библиотеки, импортированные по одному методу — используйте
import { debounce } from 'lodash-es'вместоimport _ from 'lodash'.
Предзагрузка и префетчинг: когда и как
Ленивая загрузка может создать задержку при переходе — пользователь кликает на ссылку и ждёт, пока загрузится чанк. Эту задержку можно скрыть с помощью префетчинга.
// Префетчинг при наведении курсора на ссылку
<Link
to="/checkout"
onMouseEnter={() => {
import('./pages/Checkout'); // предзагрузка чанка
}}
>
Оформить заказ
</Link>
// Или с помощью Webpack magic comments
const Checkout = lazy(() =>
import(/* webpackPrefetch: true */ './pages/Checkout')
);
Виртуализация списков и обработка больших данных
Рендер 1000+ DOM-узлов одновременно убивает производительность: браузер тратит сотни миллисекунд на layout и paint. Виртуализация решает эту проблему, рендеря только видимые элементы. Список из 100 000 строк занимает в DOM всего 10–20 элементов.
react-window vs react-virtualized: что выбрать в 2024-2025
react-virtualized — старшая библиотека, много возможностей (таблицы, сетки, списки с динамической высотой, бесконечная прокрутка), но размер ~35 KB (gzipped) и API достаточно сложен.
react-window — переписанная и упрощённая версия от того же автора. Размер всего ~6 KB (gzipped). API чище, работает быстрее. Для 95% задач достаточно react-window.
Выбирайте react-window, если: вам нужен простой список или таблица с фиксированной высотой строк. Динамическая высота элементов и сложные сетки тоже поддерживаются (VariableSizeList).
Оставайтесь на react-virtualized, если: вы уже используете его в проекте и не хотите мигрировать, или нужны коллекции с изменяемым количеством колонок (Masonry), или поддержка старых браузеров (IE11).
Пример с react-window:
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>
Строка #{index + 1}, какой-то контент
</div>
);
function LargeList({ items }) {
return (
<List
height={600} // высота контейнера (px)
itemCount={items.length} // всего элементов
itemSize={50} // высота каждого элемента (px)
width="100%"
>
{Row}
</List>
);
}
Динамическая высота элементов и таблицы с изменяемыми строками
Если строки имеют разную высоту (например, комментарии разной длины), используйте VariableSizeList. Нужно передать функцию getItemSize, которая возвращает высоту для каждого индекса.
import { VariableSizeList as List } from 'react-window';
const rowHeights = new Array(10000).fill(50).map((_, i) => 50 + (i % 5) * 10);
const getItemSize = index => rowHeights[index];
function DynamicList() {
return (
<List
height={600}
itemCount={10000}
itemSize={getItemSize}
width="100%"
>
{Row}
</List>
);
}
Для таблиц с разной шириной колонок подойдёт react-window + CSS Grid внутри ячейки, или react-virtualized с Column.
Бесконечная прокрутка + виртуализация: готовый пример
Комбинируем виртуализацию с подгрузкой данных по мере прокрутки. Используем react-window-infinite-loader.
import InfiniteLoader from 'react-window-infinite-loader';
import { FixedSizeList as List } from 'react-window';
function InfiniteList() {
const [items, setItems] = useState([]);
const [hasNextPage, setHasNextPage] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const loadMoreItems = async (startIndex, stopIndex) => {
setIsLoading(true);
const newItems = await fetchItems(startIndex, stopIndex);
setItems(prev => [...prev, ...newItems]);
setIsLoading(false);
};
const isItemLoaded = index => !!items[index];
return (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={hasNextPage ? items.length + 100 : items.length}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<List
height={600}
itemCount={items.length}
itemSize={50}
onItemsRendered={onItemsRendered}
ref={ref}
width="100%"
>
{({ index, style }) => (
<div style={style}>
{items[index]?.content || 'Загрузка...'}
</div>
)}
</List>
)}
</InfiniteLoader>
);
}
Эффективная работа с состоянием: избегаем лишних ререндеров
Неправильная организация состояния — главная причина каскадных перерисовок. Даже маленькое изменение в корне может перерендерить всё дерево.
Поднятие состояния (lifting state up) и его последствия
Поднятие состояния — базовый паттерн React для совместного использования данных между компонентами. Но он же ведёт к ререндерам всех дочерних компонентов при любом изменении состояния.
// Плохо: состояние в общем предке, которое часто меняется
function App() {
const [searchText, setSearchText] = useState('');
// каждый символ в поиске перерендерит Header, Sidebar, MainContent
return (
<div>
<Header />
<Sidebar />
<SearchInput value={searchText} onChange={setSearchText} />
<MainContent searchText={searchText} />
</div>
);
}
// Лучше: состояние только там, где нужно (в SearchInput и MainContent?)
// Но если MainContent и SearchInput не имеют общего предка, поднятие неизбежно.
// Решение: вынесите изменяемое состояние в отдельный компонент-обёртку
function SearchSection() {
const [searchText, setSearchText] = useState('');
return (
<>
<SearchInput value={searchText} onChange={setSearchText} />
<MainContent searchText={searchText} />
</>
);
}
function App() {
return (
<div>
<Header /> <!-- не перерендеривается при поиске -->
<Sidebar /> <!-- не перерендеривается -->
<SearchSection /> <!-- перерендеривается только эта часть -->
</div>
);
}
Контекст: как не перерендерить всё приложение
Контекст — удобный способ передать данные глубоко в дерево, но у него есть цена: любое изменение значения контекста перерендеривает всех потребителей (consumers), даже если они используют только часть данных.
// Проблема: контекст с несколькими полями, одно из которых часто меняется
const AppContext = React.createContext();
function App() {
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
// notifications меняется каждые 5 секунд (polling)
return (
<AppContext.Provider value={{ user, notifications }}>
<Header /> <!-- перерендерится при каждом изменении уведомлений, хотя нужен только user -->
<Sidebar />
</AppContext.Provider>
);
}
// Решение 1: разделить контексты
const UserContext = React.createContext();
const NotificationsContext = React.createContext();
// Решение 2: использовать библиотеки с селекторами (Zustand, Jotai, Redux Toolkit)
// Они позволяют подписываться только на часть состояния.
// Решение 3 (без внешних библиотек): мемоизация потребителя
const Header = React.memo(() => {
const user = useContext(UserContext);
return <div>{user?.name}</div>;
});
В React 19 ожидается React Forget — автоматический компилятор мемоизации. Но пока контекст остаётся опасным для часто меняющихся данных. Используйте его только для редких изменений (тема, язык, аутентификация).
useReducer и глобальные сторы (Redux, Zustand): когда они ускоряют
Глобальные сторы не по умолчанию быстрее, чем контекст. Они быстрее, если правильно настроены (селекторы, мемоизация). Например, в Redux Toolkit компонент, использующий useSelector, перерендерится только если выбранное значение изменилось (shallowEqual по умолчанию).
// Zustand: подписка только на нужные поля
const useStore = create((set) => ({
user: null,
notifications: [],
setUser: (user) => set({ user }),
}));
function Header() {
const user = useStore(state => state.user); // перерендер только при изменении user
return <div>{user?.name}</div>;
}
function NotificationBell() {
const notifications = useStore(state => state.notifications); // перерендер только при изменении уведомлений
return <div>{notifications.length}</div>;
}
useReducer — локальный аналог Redux. Полезен, когда у вас сложная логика обновления состояния внутри компонента (несколько полей, переходы, которые зависят друг от друга). Но он не даёт выигрыша в ререндерах по сравнению с useState.
Атомарные состояния: разделяем данные на мелкие кусочки
Атомарные сторы (Jotai, Recoil) предлагают наименьшую единицу подписки — атом. Компонент подписывается на конкретный атом и перерендеривается только когда меняется именно он.
import { atom, useAtom } from 'jotai';
const userAtom = atom(null);
const notificationsAtom = atom([]);
function Header() {
const [user] = useAtom(userAtom); // только user
return <div>{user?.name}</div>;
}
// notifications updated — Header не перерендерится, NotificationBell перерендерится
function NotificationBell() {
const [notifications] = useAtom(notificationsAtom);
return <div>{notifications.length}</div>;
}
Серверный рендеринг (SSR) и статическая генерация (SSG)
SSR часто называют «оптимизацией», но это не совсем так. SSR улучшает воспринимаемую производительность (First Contentful Paint, Time to First Byte), но может увеличить нагрузку на сервер и отсрочить интерактивность (TTI).
Когда SSR ускоряет, а когда замедляет
SSR даёт выигрыш, когда:
- У вас публичный контент для SEO (блог, каталог товаров, документация).
- Пользователи на медленных устройствах и сетях — первый экран показывается без загрузки тяжёлого JS.
- Вы используете Streaming SSR + Selective Hydration (React 18+), который гидратирует части страницы по мере взаимодействия.
SSR вредит, когда:
- Приложение требует авторизации и персональных данных — SSR не может отдать разный контент разным пользователям без задержки (придётся генерировать на лету, что нагружает сервер).
- Вы используете window/document в рендере без проверки — получите ошибки гидратации.
- У вас высоконагруженное приложение с тысячами RPS — SSR может убить сервер. В таком случае лучше использовать SSG + клиентский рендеринг.
Streaming SSR и Selective Hydration в React 18+
React 18 представил архитектуру, которая разбивает рендер на части:
- Streaming SSR — сервер отправляет HTML потоком, не дожидаясь полного рендера. Пользователь видит контент по кусочкам.
- Selective Hydration — React гидратирует (привязывает события) сначала те части страницы, с которыми пользователь взаимодействует (клик, скролл). Остальные гидратируются в фоне.
// В роутере Next.js 14+ (App Router)
// Страница автоматически использует Streaming SSR
export default async function Page() {
const data = await fetchData(); // этот рендер не блокирует поток
return <main>{data.content}</main>;
}
Next.js: практические настройки производительности
Если вы используете Next.js для оптимизации React приложения, вот ключевые настройки:
- Включите SWC минификацию — быстрее, чем Babel. В next.config.js:
swcMinify: true - Настройте Image компонент — автоматическая оптимизация изображений, lazy loading, resize.
import Image from 'next/image' - Используйте динамические импорты для клиентских компонентов:
import dynamic from 'next/dynamic'; const Heavy = dynamic(() => import('./Heavy'), { ssr: false, loading: () => <Spinner /> }) - Включите сжатие (gzip/Brotli) в настройках сервера или на уровне CDN.
- Перейдите на App Router — он оптимизирует загрузку скриптов и CSS.
- Используйте Partial Prerendering (Preview в 2025) — статические части отдаются мгновенно, динамические доставляются через поток.
Оптимизация ресурсов: изображения, шрифты, стили
React-компоненты — это только часть картины. Изображения и шрифты часто весят больше, чем весь JavaScript.
next/image, lazy loading, webp и современные форматы
Для обычного React (без Next.js) используйте стандартный loading="lazy" на тегах img, но он загружается только после рендера. Лучше использовать библиотеку react-lazy-load-image-component или @4tw/cypress-toolbox.
import { LazyLoadImage } from 'react-lazy-load-image-component';
<LazyLoadImage
alt="Product"
effect="blur"
src={product.image.webp}
placeholderSrc={product.image.placeholder}
/>
Советы по изображениям:
- Переведите изображения в WebP (или AVIF — ещё сильнее сжатие). Современные браузеры поддерживают оба. Используйте <picture> с fallback.
- Генерируйте несколько размеров одного изображения (320px, 640px, 1280px) и используйте srcset.
- Загружайте только видимую область (Intersection Observer + низкое качество для превью).
- Для иконок используйте SVG спрайты или систему иконок (например, @phosphor-icons/react — весят мало, дерево тряски работает).
CDN и кеширование: заголовки Cache-Control и Service Workers
Даже идеально оптимизированное React приложение будет медленным, если каждый раз загружать ресурсы заново.
Кеширование на уровне HTTP:
- Статические файлы (бандлы, изображения, шрифты) должны иметь заголовок
Cache-Control: max-age=31536000, immutable. - HTML (файл, который подключает бандлы) должен иметь
Cache-Control: no-cacheили короткий max-age, чтобы браузер всегда проверял обновления. - Используйте CDN (CloudFlare, AWS CloudFront, Vercel Edge) для доставки из ближайшего к пользователю региона.
Service Worker (Workbox): для PWA вы можете кешировать бандлы и API-ответы. При втором посещении страница загружается мгновенно (включая данные).
// В Next.js используйте next-pwa
// В CRA — cra-template-pwa
Продвинутые техники
Если базовых методов недостаточно, переходите к тяжёлой артиллерии.
useTransition и useDeferredValue: помечаем не срочные обновления
React 18+ позволяет разделить обновления на срочные (ввод текста, клик, анимация) и менее срочные (фильтрация большого списка, рендер графиков).
import { useTransition, useState, useDeferredValue } from 'react';
function SearchPage({ items }) {
const [searchTerm, setSearchTerm] = useState('');
const [isPending, startTransition] = useTransition();
const deferredSearchTerm = useDeferredValue(searchTerm);
const handleChange = (e) => {
// Срочное обновление: состояние поля ввода
setSearchTerm(e.target.value);
// Несрочное обновление: фильтрация списка
startTransition(() => {
const filtered = items.filter(item =>
item.name.includes(e.target.value)
);
setFilteredResults(filtered);
});
};
return (
<div>
<input value={searchTerm} onChange={handleChange} />
{isPending && <Spinner />}
<Results list={filteredResults} />
</div>
);
}
Благодаря этому инпут не будет лагать при фильтрации 10 000 элементов. useDeferredValue делает то же самое, но без необходимости вызывать startTransition вручную — возвращает «отложенную» версию значения.
Троттлинг и дебаунсинг обработчиков
Это не React-специфичные техники, но они критичны для событий, которые происходят слишком часто (scroll, resize, input).
import { debounce } from 'lodash-es';
function SearchInput() {
const [value, setValue] = useState('');
const handleSearchDebounced = useCallback(
debounce((searchTerm) => {
fetchResults(searchTerm); // Тяжёлый запрос
}, 300),
[]
);
const handleChange = (e) => {
setValue(e.target.value);
handleSearchDebounced(e.target.value);
};
return <input value={value} onChange={handleChange} />;
}
Web Workers для тяжёлых вычислений без блокировки UI
Если у вас есть вычисления, которые занимают >100ms (криптография, обработка изображений, парсинг больших CSV), вынесите их в Web Worker. Главный поток останется отзывчивым.
// worker.js
self.addEventListener('message', (e) => {
const result = heavyComputation(e.data);
self.postMessage(result);
});
// React компонент
const worker = new Worker(new URL('./worker.js', import.meta.url));
useEffect(() => {
worker.onmessage = (e) => setResult(e.data);
worker.postMessage(inputData);
return () => worker.terminate();
}, [inputData]);
Для React 18+ вы также можете использовать useTransition вместо Web Workers для многих случаев — он тоже освобождает главный поток, но не параллельно, а задачей с низким приоритетом.
Реальные кейсы: до и после оптимизации
Кейс 1: Дашборд с 10 000 строк — с 2 секунд до 30 мс
Проблема: Таблица на 10 000 строк рендерилась целиком. При скролле FPS падал до 10-15, ресайз окна зависал на 2-3 секунды.
Диагностика: React Profiler показал, что каждый рендер всей таблицы занимает ~800ms, а пересчёт стилей браузером — ещё ~1.2s.
Решение: Заменили обычную таблицу на react-window (FixedSizeList). Дополнительно оптимизировали каждый Row через React.memo.
Результат: Рендер видимой области (20 строк) — 15ms. Скролл — 60 FPS. Ресайз — 30ms.
Кейс 2: Фильтрация каталога из 5000 товаров — с 300 мс до 8 мс
Проблема: Ввод текста в поиске фильтровал 5000 товаров на каждый символ, вызывая ререндер компонента каталога и нескольких виджетов. При быстром вводе браузер подвисал.
Диагностика: why-did-you-render показал, что каталог перерендеривается из-за изменения filterFn — функция создавалась в рендере всегда.
Решение: 1) Обернули фильтрацию в useMemo с зависимостями [products, searchTerm]. 2) Использовали useCallback для функции-фильтра, передаваемой в дочерний компонент. 3) Добавили дебаунс 300ms на ввод.
Результат: Фильтрация происходит только когда пользователь остановил ввод (не на каждый символ). Время перерасчёта упало с 300ms до 8ms.
Кейс 3: Уменьшение бандла с 3.2 MB до 620 KB
Проблема: Сборка продакшена весила 3.2 MB (gzipped — 1.1 MB), что давало TTI на 3G ~12 секунд.
Диагностика: Webpack Bundle Analyzer показал, что 40% бандла — moment.js (локали), 25% — lodash (весь целиком), 15% — библиотека графиков неизвестного авторства.
Решение: Заменили moment.js на date-fns (дерево тряски). Заменили lodash на lodash-es + настроили импорт по методам. Убрали неиспользуемую библиотеку графиков, написали свой простой компонент.
Добавили code splitting по роутам. Результат: 620 KB (gzipped — 210 KB). TTI на 3G сократился до 3.2 секунды.
Полный чек-лист оптимизации React приложения (30+ пунктов)
Пройдитесь по списку перед релизом или при появлении тормозов:
- Измерение
- Зафиксировал ли метрики до оптимизации (FCP, TTI, FPS)?
- Использовал React DevTools Profiler для поиска самых долгих рендеров?
- Проверил why-did-you-render на лишние ререндеры?
- Проанализировал бандл через Webpack Bundle Analyzer?
- Мемоизация
- Дорогие вычисления обернуты в useMemo?
- Функции, передаваемые в memo-компоненты, обернуты в useCallback?
- Кастомная функция сравнения React.memo не создаёт ложных срабатываний?
- Не злоупотребляю ли я мемоизацией (нет ли 100 useMemo на странице)?
- Code splitting
- Код разделён по маршрутам (React Router + lazy)?
- Тяжёлые компоненты (модалки, графики, редакторы) загружаются лениво?
- Использованы webpackPrefetch для предзагрузки чанков при наведении?
- Библиотеки не дублируются в бандле?
- Состояние и рендеры
- Состояние поднято не выше, чем нужно?
- Контексты разделены по темам и не содержат часто меняющихся данных?
- Глобальный стор настроен на селекторы (подписка только на нужные поля)?
- Использую ли useTransition для несрочных обновлений (фильтрация, графики)?
- Добавил дебаунс или троттлинг на scroll/resize/input?
- Виртуализация
- Списки из 200+ элементов виртуализированы (react-window)?
- Таблицы с бесконечной прокруткой используют InfiniteLoader?
- Динамическая высота элементов не ломает производительность?
- Ресурсы
- Изображения используют lazy loading и современные форматы (WebP/AVIF)?
- Шрифты загружаются с font-display: swap и предзагрузкой?
- Статические файлы кешируются через CDN с длинным max-age?
- Service Worker кеширует бандлы и API для офлайн-режима?
- SSR/Next.js (если применимо)
- Включены SWC минификация и сжатие?
- Динамические импорты отключены на сервере (ssr: false) для клиентских библиотек?
- Используется компонент next/image для всех изображений?
- Настроен Streaming SSR и selective hydration (React 18+)?
Заключение: когда останавливаться
Оптимизация React приложения — это бесконечный соблазн. Всегда есть что улучшить, но помните: преждевременная оптимизация — корень всех зол (Дональд Кнут). Оптимизируйте только то, что измерили как проблему. Применяйте техники точечно. И всегда перепроверяйте, не стало ли хуже (да, мемоизация иногда замедляет код из-за лишних проверок).
Если вы прошли чек-лист, ваше приложение уже быстрее 95% сайтов в рунете. Дальнейшие улучшения дадут прирост в миллисекунды, но потребуют часов работы. Останавливайтесь, когда метрики попадают в зелёную зону Lighthouse (Performance > 90).
Главное — не гонитесь за идеальным кодом, гонитесь за отзывчивым интерфейсом для ваших пользователей. Счастливого кодинга!
Что почитать дальше
Дополнительные материалы из архива, которые могут быть полезны после этой статьи.
Оптимизация Vue приложения: полное руководство по производительности
Исчерпывающее руководство по оптимизации Vue приложений: от анализа бандла и ленивой загрузки до продвинутой оптимизаци…
Читать далее
Сложность алгоритмов в JavaScript: полное руководство по Big O
Научитесь оценивать эффективность кода через Big O: временная и пространственная сложность, примеры для массивов, объек…
Читать далее
Правила использования React хуков: как писать код без ошибок
React хуки изменили подход к разработке компонентов. Разбираем главные правила их использования: вызов только на верхне…
Читать далее