
Оптимизация в React почти всегда сводится к двум факторам: объёму работы, которую выполняет JavaScript, и частоте (а также «стоимости») перерисовок компонентов. Сам React работает достаточно быстро, но в крупных интерфейсах даже небольшие архитектурные промахи и на первый взгляд безобидные ререндеры начинают заметно бить по производительности.
В данной статье мы расскажем про ключевые подходы к оптимизации React-приложений: как уменьшить количество лишних ререндеров, сократить объём вычислений при вводе и скролле и снизить нагрузку стартового JavaScript.
Сначала измеряем, потом оптимизируем
Прежде чем что-то «ускорять», важно понять, где именно теряется производительность и что реально является узким местом.
Что измерять
Для оценки производительности фронтенда обычно используют Core Web Vitals:
LCP (скорость загрузки основного контента),
CLS (стабильность верстки),
INP (отзывчивость интерфейса на действия пользователя).
Быстрый способ увидеть метрики на реальном примере - PageSpeed Insights. Важно смотреть не только на общий score, а на конкретные значения метрик (в первую очередь LCP и CLS) и сравнивать их до/после изменений.

INP измеряет время от взаимодействия (клик, ввод, тап) до момента, когда пользователь видит обновление на экране. В рамках Core Web Vitals INP пришёл на смену FID, поэтому при анализе интерактивности и «лагов» интерфейса ориентироваться лучше именно на INP
Чем искать проблемы в React
Для быстрого первого среза удобно запускать Lighthouse прямо в Chrome DevTools. Итоговый Performance score полезен как индикатор, но дальше всегда нужно открывать детали отчёта и смотреть конкретные причины.

React Developer Tools + Profiler помогают увидеть коммиты рендера: какие компоненты перерисовывались и сколько времени это заняло. В самом React есть и программный <Profiler>, но на практике чаще достаточно DevTools.
В профайлере есть режим «почему это отрендерилось» (в DevTools он включается в настройках профайлера). Он позволяет быстро понять, какие изменения state или props вызвали ререндер.
Оптимизация без профайлера обычно превращается в бездумное использование useMemo/useCallback везде — и это часто делает только хуже.
Chrome Performance пригодится, если проблема не в React, а в layout/paint или long task.
В Lighthouse полезно смотреть блок Insights/Opportunities - это список конкретных проблем и примерной экономии. Он помогает быстро понять, упираетесь ли вы в сеть, кэш, изображения, лишний JavaScript или проблемы рендеринга (например forced reflow).

Почему компоненты вообще перерисовываются
Чтобы оптимизировать рендеринг, сначала важно понимать, откуда берутся ререндеры. Во многих случаях проблема не в «медленном React», а в том, что обновления запускаются слишком часто или затрагивают больше компонентов, чем нужно.
В упрощённом виде React-компонент перерисовывается, когда:
обновился state внутри компонента;
пришли новые props;
перерендерился родитель (и по цепочке — дочерние компоненты тоже);
изменилось значение Context у Provider (и обновились подписчики).
Важно помнить: render не всегда означает изменения в DOM. React может быстро сравнить результат и почти ничего не менять в DOM. Однако сам запуск функций компонентов и вычисления внутри них могут быть дорогими.
В dev-режиме с StrictMode многие вещи вызываются «лишний раз» специально, чтобы выявлять побочные эффекты. Это не проблема производительности в продакшене, но при отладке может вводить в заблуждение. Эти дополнительные вызовы относятся именно к dev-режиму — в продакшене их нет. Поэтому выводы о производительности лучше делать на production build или хотя бы сравнивать поведение без StrictMode, иначе легко принять «проверочную» работу React за реальную проблему.
Самые частые причины замедлений интерфейса и как их чинить
На практике проблемы производительности в React чаще всего возникают из-за лишних ререндеров, неудачного размещения состояния и «тяжёлых» операций, которые выполняются слишком часто. Ниже — типовые причины замедлений и подходы, которые помогают их устранить.
Держите state ближе к месту использования (state colocation)
Классическая проблема — один большой state «наверху», из-за которого при любом изменении начинает обновляться почти весь экран.
Плохо: верхний компонент обновляется на каждый ввод и тянет за собой соседние компоненты.
import { useState } from 'react';
export const Page = () => {
const [search, setSearch] = useState('');
const onSearch = (event) => {
setSearch(event.target.value);
};
return (
<div>
<header>
<input value={search} onChange={onSearch} />
</header>
<Sidebar />
<ProductsList />
</div>
);
};Что не так: при каждом вводе в input будет перерисовываться компонент Page, а вместе с ним — Sidebar и ProductsList, хотя значение search им вообще не нужно.
Хорошо: state находится рядом с компонентом, который его использует.
import { useState } from 'react';
const SearchInput = () => {
const [search, setSearch] = useState('');
const onSearch = (event) => {
setSearch(event.target.value);
};
return <input value={search} onChange={onSearch} />;
};
export const Page = () => {
return (
<div>
<header>
<SearchInput />
</header>
<Sidebar />
<ProductsList />
</div>
);
};Состояние должно находиться как можно ближе к тому компоненту, которому оно действительно нужно. Поднимать state выше стоит только в случаях, когда он требуется нескольким компонентам или должен влиять на данные, запросы или навигацию.
Не храните derived state в state
Derived state — это данные, которые полностью вычисляются из props или state. Хранить их отдельно почти всегда лишнее: это добавляет синхронизацию, useEffect и дополнительные ререндеры, а также риск рассинхронизации.
Плохо: дублируем производное значение (count) в state и синхронизируем через эффект.
import { useEffect, useState } from 'react';
export const ItemsInfo = ({ items }) => {
const [count, setCount] = useState(items.length);
useEffect(() => {
setCount(items.length);
}, [items]);
return (
<div>
<div>Total: {count}</div>
<ul>
{items.slice(0, 3).map((item) => (
<li key={id}>{title}</li>
))}
</ul>
</div>
);
};Хорошо: считаем derived-значение напрямую (или через useMemo, если вычисление реально дорогое).
export const ItemsInfo = ({ items }) => {
const count = items.length;
return (
<div>
<div>Total: {count}</div>
<ul>
{items.slice(0, 3).map((item) => (
<li key={id}>{title}</li>
))}
</ul>
</div>
);
};Разбивайте большие компоненты на части
Крупные монолитные компоненты сложно оптимизировать: любое изменение заставляет пересчитывать сразу всё. Декомпозиция упрощает оптимизацию — становится проще локализовать state и точечно применять memo. Само по себе разбиение не гарантирует уменьшение числа ререндеров, но делает это достижимым без «грязного» кода.
Плохо:
export const Dashboard = ({ userName }) => {
return (
<div>
<div>{userName}</div>
<div>Notifications</div>
<div>Settings</div>
</div>
);
};Хорошо:
const UserInfo = ({ userName }) => {
return <div>{userName}</div>;
};
const Notifications = () => {
return <div>Notifications</div>;
};
const Settings = () => {
return <div>Settings</div>;
};
export const Dashboard = ({ userName }) => {
return (
<div>
<UserInfo userName={userName} />
<Notifications />
<Settings />
</div>
);
};React.memo работает только при стабильных props
React.memo — это HOC, который мемоизирует результат рендера компонента. Он принимает два аргумента: компонент и (опционально) функцию сравнения пропсов.
const Memoized = React.memo(Component, arePropsEqual);По умолчанию React.memo делает поверхностное сравнение props: если пропсы «те же», ререндер пропускается. Однако для объектов, массивов и функций «то же самое» означает совпадение по ссылке, а не по содержимому. В документации прямо отмечается: если вы передаёте функцию в memo-компонент, сделайте её ссылку стабильной — например, вынесите функцию наружу или используйте useCallback. (https://react.dev/reference/react/memo)
Второй необязательный аргумент arePropsEqual(prevProps, nextProps) позволяет вручную управлять тем, когда пропускать ререндер:
возвращает true → props считаются равными → ререндер пропускается
возвращает false → ререндер выполняется
При этом важно помнить: arePropsEqual вызывается при проверке на каждое обновление, поэтому дорогие сравнения (особенно «глубокие») могут дать обратный эффект.
Плохо: при каждом ререндере создаётся новая функция, поэтому memo не даёт эффекта.
import { memo, useState } from 'react';
const SaveButton = memo(({ onSave }) => {
return <button onClick={onSave}>Save</button>;
});
export const Page = () => {
const [count, setCount] = useState(0);
const onIncrement = () => {
setCount((prev) => prev + 1);
};
return (
<div>
<SaveButton onSave={() => foo()} />
<button onClick={onIncrement}>{count}</button>
</div>
);
};Хорошо: стабильная ссылка на функцию.
import { memo, useState, useCallback } from 'react';
const SaveButton = memo(({ onSave }) => {
return <button onClick={onSave}>Save</button>;
});
export const Page = () => {
const [count, setCount] = useState(0);
const onSave = useCallback(() => {
foo();
}, []);
const onIncrement = () => {
setCount((prev) => prev + 1);
};
return (
<div>
<SaveButton onSave={onSave} />
<button onClick={onIncrement}>{count}</button>
</div>
);
};Пример: кастомное сравнение props во втором аргументе memo. Здесь ререндер SaveButton будет выполняться только если реально изменились значимые для UI пропсы (в примере — disabled). Изменения ссылки на onSave будут игнорироваться сравнением, поэтому применять такой приём стоит только тогда, когда вы уверены, что это безопасно.
import { memo, useCallback, useState } from 'react';
const SaveButton = memo(
({ onSave, disabled }) => {
return (
<button onClick={onSave} disabled={disabled}>
Save
</button>
);
},
(prevProps, nextProps) => {
return prevProps.disabled === nextProps.disabled;
}
);
export const Page = () => {
const [count, setCount] = useState(0);
const [disabled, setDisabled] = useState(false);
const onIncrement = () => {
setCount((prev) => prev + 1);
};
const onToggleDisabled = () => {
setDisabled((prev) => !prev);
};
const onSave = useCallback(() => {
foo();
}, []);
return (
<div>
<SaveButton onSave={onSave} disabled={disabled} />
<button onClick={onIncrement}>{count}</button>
<button onClick={onToggleDisabled}>
Toggle disabled: {String(disabled)}
</button>
</div>
);
};
useCallback и useMemo — не «ускоритель по умолчанию». Эти инструменты имеют смысл, когда:
вы действительно упираетесь в лишние ререндеры из-за нестабильных ссылок;
у вас есть дорогие вычисления, которые не должны повторяться на каждый ререндер.
Почему useMemo/useCallback иногда делают хуже:
это тоже работа: React должен хранить кэш, сравнивать зависимости и поддерживать эту логику;
код становится сложнее: легко ошибиться в зависимостях и получить баги (устаревшие значения, неожиданные эффекты);
мемоизация не лечит «тяжёлый UI»: если проблема в большом списке, дорогих layout/paint или тяжёлых дочерних компонентах, useMemo не спасёт — там нужны виртуализация, декомпозиция и отложенные обновления.
Кроме того, React постепенно движется в сторону автоматической мемоизации через React Compiler, и в документации отмечается, что это снижает потребность в ручном использовании useCallback.
useMemo — для вычислений, а не для «магии»
useMemo кэширует результат вычисления между ререндерами. Он полезен в случаях, когда вычисление действительно тяжёлое — например, при фильтрации и сортировке больших массивов.
Плохо: сортировка выполняется на каждый ввод, даже если сам список не менялся.
import { useState } from 'react';
export const Products = ({ items }) => {
const [query, setQuery] = useState('');
const filteredItems = items
.filter((item) => item.toLowerCase().includes(query.toLowerCase()))
.sort((firstItem, secondItem) => firstItem.localeCompare(secondItem));
const handleQueryChange = (event) => {
setQuery(event.target.value);
};
const itemsToDisplay = filteredItems.join(', ');
return (
<>
<input value={query} onChange={handleQueryChange} />
<div>{itemsToDisplay}</div>
</>
);
};Хорошо: кэшируем тяжёлую часть и пересчитываем её только тогда, когда меняются items или query.
import { useState, useMemo } from 'react';
export const Products = ({ items }) => {
const [query, setQuery] = useState('');
const filteredItems = useMemo(() => {
const normalizedQuery = query.toLowerCase();
return items
.filter((item) => item.toLowerCase().includes(normalizedQuery))
.sort((firstItem, secondItem) => firstItem.localeCompare(secondItem));
}, [items, query]);
const handleQueryChange = (event) => {
setQuery(event.target.value);
};
const itemsToDisplay = filteredItems.join(', ');
return (
<>
<input value={query} onChange={handleQueryChange} />
<div>{itemsToDisplay}</div>
</>
);
};
Context — один большой Provider может влиять на половину приложения
Если в одном Context хранится и тема, и авторизация, и другие данные, то любое изменение value приводит к обновлению всех consumers, даже если им нужна только небольшая часть состояния.
Плохо: один контекст используется «на всё».
import { createContext, useContext, useState } from 'react';
const AppContext = createContext(null);
const Header = () => {
const context = useContext(AppContext);
if (!context) {
return null;
}
const textToDisplay = context.state.isLoggedIn ? 'Welcome' : 'Please log in';
return <div>{textToDisplay}</div>;
};
const Content = () => {
const context = useContext(AppContext);
if (!context) {
return null;
}
return <div className={context.state.theme}>Main</div>;
};
export const App = () => {
const [state, setState] = useState({ isLoggedIn: false, theme: 'light' });
return (
<AppContext.Provider value={{ state, setState }}>
<Header />
<Content />
</AppContext.Provider>
);
};
Хорошо: разделить контексты по смыслу
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext(false);
const ThemeContext = createContext('light');
const Header = () => {
const isLoggedIn = useContext(AuthContext);
const textToDisplay = isLoggedIn ? 'Welcome' : 'Please log in';
return <div>{textToDisplay}</div>;
};
const Content = () => {
const theme = useContext(ThemeContext);
return <div className={theme}>Main</div>;
};
export const App = () => {
const [isLoggedIn] = useState(false);
const [theme] = useState('light');
return (
<AuthContext.Provider value={isLoggedIn}>
<ThemeContext.Provider value={theme}>
<Header />
<Content />
</ThemeContext.Provider>
</AuthContext.Provider>
);
};
Если value — это объект, важно следить за его ссылкой. Часто value нужно мемоизировать через useMemo, иначе Provider будет отдавать новый объект на каждый ререндер.
Плохо: на каждый ререндер создаётся новый объект.
<AppContext.Provider value={{ isLoggedIn, theme }}>
{children}
</AppContext.Provider>Хорошо: сделать ссылку на объект стабильной с помощью useMemo.
import { useMemo } from 'react';
const contextValue = useMemo(() => {
return { isLoggedIn, theme };
}, [isLoggedIn, theme]);
return (
<AppContext.Provider value={contextValue}>
{children}
</AppContext.Provider>
)Списки — key и виртуализация
В списках производительность и корректность часто упираются в две вещи: стабильные key и виртуализацию при больших объёмах данных.
Нестабильные key (например, индекс) ломают повторное использование элементов и могут приводить к лишним обновлениям и визуальным артефактам.
Плохо: используется индекс в качестве key.
export const TodoList = ({ todos }) => {
return (
<ul>
{todos.map((item, index) => (
<li key={index}>{item.text}</li>
))}
</ul>
);
};Хорошо:
export const TodoList = ({ todos }) => {
return (
<ul>
{todos.map(({ text, id }) => (
<li key={id}>{text}</li>
))}
</ul>
);
};Виртуализация
Если у вас список на тысячи строк, ключевая оптимизация — не борьба с ререндерами, а сокращение числа DOM-узлов: держать в разметке одновременно тысячи элементов дорого для layout/paint и часто даёт лаги при скролле и вводе. В таких случаях помогает виртуализация: на экране рендерятся только видимые строки, а остальное подставляется по мере прокрутки. Обычно для этого используют react-window или react-virtualized.
Важно: виртуализация — не «ставим всегда». Она усложняет реализацию (динамические высоты, измерения, скролл-позиции, scrollTo, фокус/клавиатура, доступность) и имеет смысл, когда проблема уже подтверждена: вы видите подтормаживания со списками и по измерениям (Profiler/Performance/INP) упираетесь именно в объём DOM и рендеринг.
Тяжёлые обновления при вводе — useDeferredValue и useTransition
При вводе текста ключевая задача — сохранить отзывчивость интерфейса. Для этого React предоставляет useDeferredValue и useTransition. Они помогают разделить «срочные» обновления (ввод) и «тяжёлые» (например, обновление большого списка).
Пример: фильтрация большого списка. Поле ввода должно реагировать мгновенно, а список может обновляться «чуть позже».
import { useDeferredValue, useMemo, useState } from 'react';
export const Search = ({ items }) => {
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const filteredItems = useMemo(() => {
const normalizedQuery = deferredSearchQuery.toLowerCase();
return items.filter((item) => item.toLowerCase().includes(normalizedQuery));
}, [items, deferredSearchQuery]);
const handleSearchChange = (event) => {
setSearchQuery(event.target.value);
};
const textToDisplay = `Found: ${filteredItems.length}`;
return (
<>
<input value={searchQuery} onChange={handleSearchChange} />
<div>{textToDisplay}</div>
</>
);
};Если у вас есть «тяжёлое обновление состояния» (например, применение фильтров или переключение вкладок с тяжёлым контентом), его можно обернуть в transition, чтобы сохранить отзывчивость интерфейса:
import { useTransition, useState, useMemo } from 'react';
export const Filters = ({ items }) => {
const [searchQuery, setSearchQuery] = useState('');
const [isPending, startTransition] = useTransition();
const filteredItems = useMemo(() => {
const normalizedQuery = searchQuery.toLowerCase();
return items.filter((item) => item.toLowerCase().includes(normalizedQuery));
}, [items, searchQuery]);
const handleOnClick = () => {
startTransition(() => {
setSearchQuery((previousQuery) => `${previousQuery}a`);
});
};
const textToDisplay = isPending ? 'Updating' : `Items: ${filteredItems.length}`;
return (
<>
<button onClick={handleOnClick}>Add filter</button>
<div>{textToDisplay}</div>
</>
);
};
Code splitting — грузите тяжёлое только когда нужно
Самый простой выигрыш по скорости старта — не включать в initial bundle то, что нужно только на части экранов. Для этого используют React.lazy в связке с <Suspense>, чтобы подгружать тяжёлые компоненты по требованию.
Перед тем как резать код, полезно понять, что именно раздувает бандл. Для этого обычно подключают анализатор бандла — например, webpack-bundle-analyzer (webpack) или rollup-plugin-visualizer (Vite/Rollup). Он показывает, какие зависимости занимают больше всего места, и помогает найти реальные «кандидаты» на вынос в отдельный чанк (редкие страницы, редакторы, графики, карты, WYSIWYG и т.п.).
import { lazy, Suspense, useState } from 'react';
const Settings = lazy(() => import('./Settings'));
export const App = () => {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(true)}>Open settings</button>
{open ? (
<Suspense fallback={<div>Loading...</div>}>
<Settings />
</Suspense>
) : null}
</div>
);
};Практический ориентир: code splitting лучше всего работает для маршрутов и редких фич, а не для мелких компонентов — иначе вы можете получить много чанков и дополнительные задержки на запросы.
Частая ловушка — эффекты зависят от объектов и функций, которые создаются на каждый ререндер
Распространённая проблема: в зависимости useEffect попадает объект или функция, которые пересоздаются на каждом ререндере. Тогда эффект запускается снова и снова, а в сочетании с setState это легко превращается в цикл запросов.
Плохо: options каждый раз новый, поэтому эффект постоянно срабатывает заново.
import { useState, useEffect } from 'react';
export const Users = ({ orgId }) => {
const [users, setUsers] = useState([]);
const options = { headers: { 'x-org': orgId } };
useEffect(() => {
const loadUsers = async () => {
const response = await fetch('/api/users', options);
const data = await response.json();
setUsers(data);
};
loadUsers();
}, [options]);
return <div>{users.length}</div>;
};
Хорошо: сделать зависимость примитивной (например, orgId) или мемоизировать объект, чтобы ссылка оставалась стабильной.
import { useState, useEffect } from 'react';
export const Users = ({ orgId }) => {
const [users, setUsers] = useState([]);
useEffect(() => {
const options = { headers: { 'x-org': orgId } };
const loadUsers = async () => {
const response = await fetch('/api/users', options);
const data = await response.json();
setUsers(data);
};
loadUsers();
}, [orgId]);
return <div>{users.length}</div>;
};Сеть и данные — не стреляйте запросами на каждый ввод
Частая причина проблем — не ререндеры, а сеть и «тяжёлая работа» на каждый символ: запрос на каждый ввод, гонки ответов, лишние данные и отсутствие отмены. Это приводит и к лагам, и к «дёрганому» UI, и к лишней нагрузке на API.
Важно: проблема бывает не только в сети. Даже при локальном поиске (фильтрация/сортировка на клиенте) без ограничений легко получить подвисания: пользователь вводит несколько символов подряд, а обработка предыдущего ввода ещё не закончилась — из-за этого следующий символ может ощущаться «с задержкой» (падает отзывчивость, растёт INP). Поэтому техники вроде debounce полезны не только для API, но и для тяжёлых вычислений по вводу.
Debounce для поиска
Если вы отправляете запросы по вводу, обычно нужен debounce (например, 300 мс), чтобы не дергать API на каждый символ и не перегружать главный поток лишней работой.
import { useState, useEffect } from 'react';
export const SearchUsers = () => {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
useEffect(() => {
const timeoutId = setTimeout(() => {
setDebouncedQuery(query);
}, 300);
return () => clearTimeout(timeoutId);
}, [query]);
const handleChange = (event) => {
setQuery(event.target.value);
};
return (
<>
<input value={query} onChange={handleChange} />
<div>Debounced: {debouncedQuery}</div>
</>
);
};Отмена fetch через AbortController
Даже при наличии debounce полезно отменять предыдущий запрос, чтобы его ответ не перезаписал более свежий результат и чтобы не держать лишние запросы «в полёте».
import { useState, useEffect } from 'react';
export const Users = ({ query }) => {
const [users, setUsers] = useState([]);
useEffect(() => {
const abortController = new AbortController();
const loadUsers = async () => {
try {
const data = await fetchUsers({
query,
signal: abortController.signal,
});
setUsers(data);
} catch (error) {
if (error.name === 'AbortError') {
return;
}
return;
}
};
loadUsers();
return () => abortController.abort();
}, [query]);
return <div>{users.length}</div>;
};На практике такие фрагменты кода обычно выносят в отдельные хуки (например, useDebouncedValue, useAbortableRequest), чтобы не размазывать debounce и AbortController по компонентам и не дублировать одну и ту же логику. Это базовая гигиена, которая снижает нагрузку и делает UI стабильнее — особенно на слабых устройствах и при медленной сети.
Итог
Оптимизация React-приложений на практике почти всегда сводится к снижению «лишней работы»: реже запускать тяжёлые обновления, ограничивать область ререндеров и уменьшать стоимость операций на главном потоке. Начинать стоит с измерений (Lighthouse/Profiler/Performance, Core Web Vitals), чтобы понимать, что именно ухудшает LCP/INP.
Дальше работают базовые приёмы: держать state ближе к месту использования, не хранить derived state, аккуратно применять React.memo/useMemo/useCallback (только там, где есть подтверждённая польза), правильно проектировать Context и следить за стабильностью ссылок. Для больших списков ключевое — стабильные key и, при реальных лагах, виртуализация. А для скорости старта — code splitting и загрузка тяжёлых модулей только там, где они действительно нужны.
