
Оптимизация в 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 запускает пересчёт UI (render phase) для компонента, когда:
обновился state внутри компонента;
перерисовался родительский компонент;
изменилось значение Context у Provider (и обновились подписчики).
Перерисовывается родительский компонент — React заново вызывает дочерний компонент как функцию и передаёт ему props (они могут измениться или остаться теми же).
Но финальное обновление DOM (commit phase) происходит только если результат рендера отличается от предыдущего.
Оптимизация: компоненты с React.memo или PureComponent могут пропустить даже фазу рендера, если их пропсы считаются неизменившимися (поверхностное сравнение).
В 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(({ id, title }) => ( <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 и загрузка тяжёлых модулей только там, где они действительно нужны.
