Медленный интерфейс раздражает — пользователь жмёт кнопку и видит зависание. Обычно разработчик в ответ начинает обвешивать компоненты 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 — как тестировать поведение компонентов, а не детали реализации.