← назад к разделу

Медленный интерфейс раздражает — пользователь жмёт кнопку и видит зависание. Обычно разработчик в ответ начинает обвешивать компоненты useMemo и React.memo «на всякий случай», и в результате код становится сложнее, а интерфейс быстрее не становится. Разберём, как рендеринг устроен на самом деле и что реально влияет на скорость.

Как React рисует экран

Когда что-то меняется — состояние или пропсы, — React заново вызывает функцию компонента и строит новое виртуальное дерево. Потом сравнивает его со старым и обновляет только те узлы реального DOM, которые изменились.

Вот почему перерисовка компонента ≠ медленно. Сам JavaScript-вызов функции занимает микросекунды. Дорого — менять реальный DOM, а React старается делать это минимально.

Признак настоящей проблемы — видимая задержка: пользователь нажал, и интерфейс завис на полсекунды. Не «компонент рендерится 12 раз», а именно ощутимое торможение.

Как найти реальное узкое место

Прежде чем что-то оптимизировать, нужно убедиться, что проблема есть и понять где именно. Для этого есть два инструмента.

React DevTools Profiler — запускаете приложение, открываете вкладку Profiler, нажимаете «Record», воспроизводите медленное действие, останавливаете запись. Профайлер показывает, какой компонент сколько времени занял и почему перерендерился.

Lighthouse в браузере — показывает метрики загрузки: First Contentful Paint, Time to Interactive. Подсказывает, что мешает первой загрузке.

Правило простое: сначала измерь, потом оптимизируй. Иначе тратишь время на то, что не болит.

memo, useMemo, useCallback — три инструмента точечно

Эти три хука нужны не везде, а там, где профайлер показал проблему.

React.memo

Оборачивает компонент так, что он пропускает перерисовку, если пропсы не изменились по ссылке. Помогает, когда родительский компонент часто рендерится, а дочерний — дорогой и получает те же данные.

const ProductCard = React.memo(({ product }: { product: Product }) => {
  return <div>{product.name}</div>;
});

Без React.memo каждый рендер родителя перерисует ProductCard, даже если product не изменился.

useMemo

Кеширует результат вычисления между рендерами. Полезен, когда вычисление действительно тяжёлое — например, сортировка большого массива.

const sorted = useMemo(
  () => products.slice().sort((a, b) => a.price - b.price),
  [products],
);

Пересчитывает только когда изменился products. При каждом рендере сравнение зависимостей занимает время — поэтому на дешёвых вычислениях useMemo стоит дороже, чем просто посчитать заново.

useCallback

Стабилизирует ссылку на функцию между рендерами. Нужен, когда функцию передают в React.memo-компонент или в массив зависимостей другого хука.

const handleClick = useCallback(() => {
  dispatch({ type: "ADD", id });
}, [id, dispatch]);

Без useCallback при каждом рендере создаётся новая функция с новой ссылкой — React.memo воспринимает это как изменение пропса и перерисовывает потомка.

Когда эти инструменты вредят

На простых компонентах мемоизация приносит больше вреда, чем пользы:

  • useMemo на коротком вычислении медленнее самого вычисления;
  • useCallback без React.memo-потомка — просто лишний код;
  • обмемоизированный код труднее читать и понимать.

Эвристика: не добавляй мемоизацию, пока профайлер не показал, что без неё медленно.

Code-splitting — не грузить лишнее сразу

Вторая причина медленного старта — браузер скачивает весь код приложения целиком, хотя пользователю нужна только стартовая страница.

React.lazy в паре с Suspense решает это: тяжёлый раздел загружается только когда пользователь туда переходит.

const Dashboard = React.lazy(() => import("./Dashboard"));
const ReportEditor = React.lazy(() => import("./ReportEditor"));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/reports/edit" element={<ReportEditor />} />
      </Routes>
    </Suspense>
  );
}

Первый экран грузит только свой код. Тяжёлый редактор или графики подтягиваются по требованию. Это напрямую ускоряет первую загрузку — метрику, которую пользователь чувствует.

Размер бандла

Даже с code-splitting каждый чанк может быть раздутым. Самая частая причина — библиотека, подключённая целиком ради одной функции.

Чтобы понять, что весит, используют анализаторы бандла: webpack-bundle-analyzer или vite-bundle-visualizer. Они строят интерактивную карту и сразу видно, что занимает место.

Частые находки:

  • moment.js или date-fns целиком вместо именованного импорта нужных функций;
  • иконочная библиотека, из которой используется 3 иконки;
  • дублирование зависимостей разных версий.

После измерения — конкретные действия: именованные импорты вместо дефолтных, замена тяжёлой библиотеки лёгкой альтернативой, удаление неиспользуемых зависимостей.

Коротко

  • Перерисовка React-компонента сама по себе дешёвая — дорого менять реальный DOM, а React это минимизирует.
  • Реальная проблема видна глазом: ощутимая задержка при вводе или прокрутке.
  • Инструменты для поиска: React DevTools Profiler — для перерисовок, Lighthouse — для загрузки.
  • React.memo — пропускает перерисовку, если пропсы не изменились.
  • useMemo — кеширует результат дорогого вычисления.
  • useCallback — стабилизирует ссылку на функцию для memo-потомков.
  • Мемоизацию добавляют точечно по данным профайлера, не превентивно.
  • React.lazy + Suspense — code-splitting по маршрутам, быстрее первая загрузка.
  • Анализатор бандла покажет, что весит лишнего; часто виновата одна тяжёлая библиотека.

Что почитать дальше

  • Управление состоянием — как устроено состояние в React и когда нужен внешний стор.
  • Обработка ошибок и Suspense — как Error Boundary работает вместе с Suspense.
  • Тестирование: Vitest и Testing Library — как тестировать поведение компонентов, а не детали реализации.