Оптимизация 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 реально нужен:

  1. Вычисления, которые работают за O(n²) или O(n×m) на больших массивах (фильтрация 10 000+ элементов, сложные агрегации, рекурсивные обходы).
  2. Создание объектов или массивов, которые передаются в React.memo как пропсы (чтобы сохранить ссылку).
  3. Подготовка данных для дорогих дочерних компонентов.

Пример с бенчмарком:

// Дорогая функция — сортировка и фильтрация 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 приложения, вот ключевые настройки:

  1. Включите SWC минификацию — быстрее, чем Babel. В next.config.js: swcMinify: true
  2. Настройте Image компонент — автоматическая оптимизация изображений, lazy loading, resize. import Image from 'next/image'
  3. Используйте динамические импорты для клиентских компонентов: import dynamic from 'next/dynamic'; const Heavy = dynamic(() => import('./Heavy'), { ssr: false, loading: () => <Spinner /> })
  4. Включите сжатие (gzip/Brotli) в настройках сервера или на уровне CDN.
  5. Перейдите на App Router — он оптимизирует загрузку скриптов и CSS.
  6. Используйте 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+ пунктов)

Пройдитесь по списку перед релизом или при появлении тормозов:

  1. Измерение
    • Зафиксировал ли метрики до оптимизации (FCP, TTI, FPS)?
    • Использовал React DevTools Profiler для поиска самых долгих рендеров?
    • Проверил why-did-you-render на лишние ререндеры?
    • Проанализировал бандл через Webpack Bundle Analyzer?
  2. Мемоизация
    • Дорогие вычисления обернуты в useMemo?
    • Функции, передаваемые в memo-компоненты, обернуты в useCallback?
    • Кастомная функция сравнения React.memo не создаёт ложных срабатываний?
    • Не злоупотребляю ли я мемоизацией (нет ли 100 useMemo на странице)?
  3. Code splitting
    • Код разделён по маршрутам (React Router + lazy)?
    • Тяжёлые компоненты (модалки, графики, редакторы) загружаются лениво?
    • Использованы webpackPrefetch для предзагрузки чанков при наведении?
    • Библиотеки не дублируются в бандле?
  4. Состояние и рендеры
    • Состояние поднято не выше, чем нужно?
    • Контексты разделены по темам и не содержат часто меняющихся данных?
    • Глобальный стор настроен на селекторы (подписка только на нужные поля)?
    • Использую ли useTransition для несрочных обновлений (фильтрация, графики)?
    • Добавил дебаунс или троттлинг на scroll/resize/input?
  5. Виртуализация
    • Списки из 200+ элементов виртуализированы (react-window)?
    • Таблицы с бесконечной прокруткой используют InfiniteLoader?
    • Динамическая высота элементов не ломает производительность?
  6. Ресурсы
    • Изображения используют lazy loading и современные форматы (WebP/AVIF)?
    • Шрифты загружаются с font-display: swap и предзагрузкой?
    • Статические файлы кешируются через CDN с длинным max-age?
    • Service Worker кеширует бандлы и API для офлайн-режима?
  7. SSR/Next.js (если применимо)
    • Включены SWC минификация и сжатие?
    • Динамические импорты отключены на сервере (ssr: false) для клиентских библиотек?
    • Используется компонент next/image для всех изображений?
    • Настроен Streaming SSR и selective hydration (React 18+)?

Заключение: когда останавливаться

Оптимизация React приложения — это бесконечный соблазн. Всегда есть что улучшить, но помните: преждевременная оптимизация — корень всех зол (Дональд Кнут). Оптимизируйте только то, что измерили как проблему. Применяйте техники точечно. И всегда перепроверяйте, не стало ли хуже (да, мемоизация иногда замедляет код из-за лишних проверок).

Если вы прошли чек-лист, ваше приложение уже быстрее 95% сайтов в рунете. Дальнейшие улучшения дадут прирост в миллисекунды, но потребуют часов работы. Останавливайтесь, когда метрики попадают в зелёную зону Lighthouse (Performance > 90).

Главное — не гонитесь за идеальным кодом, гонитесь за отзывчивым интерфейсом для ваших пользователей. Счастливого кодинга!