Когда наше React-приложение растёт, управление состоянием через Context API кажется естественным выбором. Беда в том, что без должной осторожности эта удобная функция превращается в мину производительности, вызывая каскад лишних ререндеров даже там, где ничего не менялось.
Проблема коренится в механизме работы Context: любой компонент, использующий useContext
, перерисовывается при изменении значения контекста – всего значения, даже если компоненту нужна лишь малая его часть.
Анатомия проблемы: почему компоненты прыгают без причины
Рассмотрим типичный сценарий:
const UserContext = createContext();
function App() {
const [user, setUser] = useState({ name: 'Alex', theme: 'dark' });
const [notifications, setNotifications] = useState([]);
return (
<UserContext.Provider value={{ user, notifications }}>
<Header /> {/* Требует только user.name */}
<NotificationCenter /> {/* Требует только notifications */}
</UserContext.Provider>
);
}
Когда добавляется новое уведомление:
notifications
обновляется- Значение контекста изменяется (так как меняется один из элементов объекта)
- И Header, и NotificationCenter ререндерятся
- Header перерисовывается несмотря на то, что
user.name
не изменился
Это происходит потому, что React проверяет изменение самого объекта контекста, а не его отдельных полей. Новый объект { user, notifications }
всегда создаётся при рендере провайдера, что воспринимается как изменение контекста.
Тактики оптимизации: от базовых к продвинутым
1. Дробим контексты по семантике
Самый действенный способ – разделение контекстов по логическим доменам:
const UserContext = createContext();
const NotificationsContext = createContext();
function App() {
const [user] = useState({ name: 'Alex', theme: 'dark' });
const [notifications, setNotifications] = useState([]);
return (
<UserContext.Provider value={user}>
<NotificationsContext.Provider value={notifications}>
<Header /> {/* Консумляет UserContext */}
<NotificationCenter /> {/* Консумляет NotificationsContext */}
</NotificationsContext.Provider>
</UserContext.Provider>
);
}
Теперь изменение уведомлений не затрагивает потребителей пользовательского контекста и наоборот.
2. Статичные значения через useMemo
Когда часть данных в контексте статична или изменяется редко, используйте мемоизацию:
const AuthContext = createContext();
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = useCallback(/* логика входа */, []);
const value = useMemo(() => ({ user, login }), [user, login]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
Это предотвращает создание нового объекта на каждом рендере провайдера. Важно: если login
определяется внутри компонента, всегда оборачивайте его в useCallback
, иначе мемоизация ломается.
3. Львиная доля оптимизации: React.memo + примитивные селекторы
Когда разделение контекстов невозможно, сочетайте React.memo
и селекторы:
const MyContext = createContext();
const UserAvatar = memo(({ username }) => {
return <Avatar name={username} />;
});
function ContextUser() {
const { user } = useContext(MyContext);
// Ререндерится только когда меняется именно user.name!
return <UserAvatar username={user.name} />;
}
Здесь дочерний компонент принимает примитив вместо объекта. При спаренном использовании с memo
, он будет перерисовываться только когда user.name
реально изменится.
4. Кастомные хуки с селекторами
Для сложных случаев выделяем логику селекторов в хук:
function useUserTheme() {
const context = useContext(UserContext);
return useMemo(() => ({
theme: context.user.theme,
setTheme: context.setTheme
}), [context.user.theme, context.setTheme]);
}
function ThemeSwitcher() {
const { theme } = useUserTheme(); // Перерисуется только если сменилась тема
return <Toggle theme={theme} />;
}
Мемоизация результата селектора гарантирует, что компонент получит один и тот же объект, пока не изменится нужное поле. Используйте такую технику для производных данных.
Продвинутые архитектурные решения
Transform contexts через подписчиков
Паттерн, напоминающий Redux: передаём не данные, но объект с подписками:
const Context = createContext();
function Provider({ children }) {
const [state, setState] = useState({ books: [], filter: '' });
const api = useMemo(() => ({
get books() {
return state.books;
},
addBook: (book) => setState(prev => ({ ...prev, books: [...prev.books, book] }))
}), [state]);
return <Context.Provider value={api}>{children}</Context.Provider>;
}
function BookList() {
const { books } = useContext(Context);
}
Такой контекст не триггерит ререндеры у потребителей при изменении любого поля состояния – только при вызове методов, явно меняющих данные. Ловушка: избыточное использование геттеров снижает читаемость.
Когда Context подходит идеально
Контекст был создан именно для статичных/редко изменяемых данных:
- Тема интерфейса
- Локализация
- Статичная конфигурация
- Инстансы API (ApolloClient, Axios)
- Данные пользователя (вошедшего в систему)
Для высокочастотных обновлений (drag-and-drop, формы с живым предпросмотром) рассмотрите библиотеки вроде Zustand, Jotai или традиционный Redux.
Профилирование для выявления проблем
Никогда не оптимизируйте вслепую. Аналогия в документации React – стетоскоп хирурга:
- React DevTools Profiler фиксирует последовательность рендеров
- Выделение компонентов цветом в React DevTools быстро показывает "прыгающие" элементы
- Проверка пропсов через
whyDidYouRender
помогает понять причины ререндеров
Поместите профилировщик на продакшен-сборку (sourcemaps помогут). Часто проблемы проявляются только под реальной нагрузкой.
function ExpensiveComponent() {
// Логика компонента
}
ExpensiveComponent.whyDidYouRender = true; // Начнёт логировать причины ререндеров
Заключение: баланс вместо крайностей
Оптимизация Context API – это баланс между простотой управления состоянием и производительностью. Начинайте с разделения контекстов, затем прибегайте к useMemo
и React.memo
. Для данных, изменяющихся чаще раза в секунду, подумайте о специализированных решениях.
Код – всегда компромисс. Иногда проще добавить React.memo
, чем перекраивать всю структуру контекстов. Главное – делать осознанный выбор, понимая варианты и ограничения. В следующий раз, когда ваш интерфейс начнёт подтормаживать – знайте: проблема редко в "сложном контексте", но почти всегда в неэффективных ререндерах. А теперь за инструментарий – профилировать и оптимизировать!