Большинство React-разработчиков начинают с useState
для управления состоянием компонентов. Это работает отлично, пока мы имеем дело с локальными, изолированными данными. Но рано или поздно возникает необходимость разделять состояние между далекими компонентами. Первой реакцией часто становится "пропс-дриллинг" – передача данных через множество промежуточных компонентов. Это не просто утомительно; это создает хрупкие связи и усложняет рефакторинг.
Context API появился как встроенное решение для передачи данных без явной передачи пропсов. Но его часто критикуют за недостатки в производительности и предсказуемости обновлений. Тем не менее, в сочетании с useReducer
он образует мощную пару, способную удовлетворить потребности многих приложений без привлечения тяжеловесных библиотек.
Почему Context + useReducer, а не только Context с useState?
Подход с прокидыванием useState
через контекст кажется простым:
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
Проблемы:
- Избыточные ререндеры: Любой компонент, подписанный на контекст, ререндерится при любом изменении
user
илиsetUser
(даже если он использует толькоsetUser
). - Отсутствие структуры: Свобода прямого вызова
setUser
из любого места затрудняет отслеживание изменений состояния, особенно в сложных цепочках. - Проблемы с зависимостями: При использовании одного контекста для разных типов данных ререндеры становятся неизбирательными.
useReducer
решает эти проблемы, вводя дисциплину:
- Состояние обновляется через предсказуемые действия (
actions
) с четкой сигнатурой - Логика изменений инкапсулирована в редьюсере
- Возможность сложных преобразований состояния в одном месте
Паттерн: Контекст + Редуктор
Начнем с создания хранилища (store):
// ThemeContext.js
import React, { createContext, useContext, useReducer, useMemo } from 'react';
const initialState = {
theme: 'light',
systemTheme: 'dark',
userOverride: null,
};
function themeReducer(state, action) {
switch (action.type) {
case 'SET_THEME':
return { ...state, userOverride: action.payload };
case 'SYNC_SYSTEM_THEME':
return {
...state,
systemTheme: action.payload,
theme: state.userOverride || action.payload
};
case 'RESET_OVERRIDE':
return { ...state, userOverride: null, theme: state.systemTheme };
default:
return state;
}
}
const ThemeStateContext = createContext(null);
const ThemeDispatchContext = createContext(null);
export function ThemeProvider({ children }) {
const [state, dispatch] = useReducer(themeReducer, initialState);
// Мемоизация контекста для оптимизации ререндеров
const stateContextValue = useMemo(() => state, [state]);
const dispatchContextValue = useMemo(() => dispatch, [dispatch]);
return (
<ThemeStateContext.Provider value={stateContextValue}>
<ThemeDispatchContext.Provider value={dispatchContextValue}>
{children}
</ThemeDispatchContext.Provider>
</ThemeStateContext.Provider>
);
}
// Хуки для доступа
export function useThemeState() {
const context = useContext(ThemeStateContext);
if (context === null) throw new Error('Missing ThemeProvider');
return context;
}
export function useThemeDispatch() {
const context = useContext(ThemeDispatchContext);
if (context === null) throw new Error('Missing ThemeProvider');
return context;
}
Ключевые решения и нюансы:
-
Разделение контекстов: Отдельный контекст для состояния (
ThemeStateContext
) и диспетчера (ThemeDispatchContext
) обеспечивает точечные ререндеры. Компонент, использующий только диспетчер, никогда не ререндерится при изменении состояния. -
Мемоизация объектов контекста: Использование
useMemo
для значений контекста предотвращает создание новых ссылок на объекты при каждом рендереThemeProvider
, что устраняет ненужные ререндеры потребителей. -
Строго типизированные действия: Каждое действие (
action
) имеет тип (type
) и понятные полезные данные (payload
). Это делает систему предсказуемой:
// Пример использования в компоненте
function ThemeToggle() {
const dispatch = useThemeDispatch();
const handleToggle = () => {
dispatch({ type: 'SET_THEME', payload: 'dark' });
};
return <button onClick={handleToggle}>Dark Mode</button>;
}
- Упрощение бизнес-логики в компонентах: Компоненты не содержат сложной логики обновления — только отправка действий:
function SystemThemeListener() {
const dispatch = useThemeDispatch();
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => {
dispatch({ type: 'SYNC_SYSTEM_THEME', payload: e.matches ? 'dark' : 'light' });
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [dispatch]);
return null;
}
Оптимизация производительности:
Даже с раздельными контекстами потребители состояния будут ререндериться при изменении любой части объекта состояния. Для компонентов, зависящих только от части состояния — используйте селекторы:
function useTheme() {
const state = useThemeState();
return useMemo(() => state.theme, [state.theme]); // Результат мемоизируется
}
Или выносите потребление контекста в минимально необходимые компоненты. Для часто изменяющихся данных рассмотрите контексты с высокоуровневой гранулярностью.
Когда этого достаточно (а когда — нет)
Контекст + useReducer эффективен для:
- Глобальных настроек (тема, язык)
- Профиля пользователя
- Некритичных к производительности данных в малопроизводительных приложениях
Рассмотрите Redux, Zustand или MobX если:
- Частые, объемные обновления состояния (панели данных)
- Сложная асинхронная логика с thunks/sagas
- Жесткие требования к производительности особенно в интенсивных интерфейсах
- Требование инструментов разработчика (DevTools) для отладки
Остерегайтесь типичных ошибок:
-
Единый монолитный контекст для всего приложения: Это антипаттерн. Дробите контексты по логическим доменам (пользователь, настройки, данные) чтобы ограничить распространение ререндеров.
-
Изменение состояния внутри контекста вместо отправки действий: Нарушает поток однонаправленности данных и усложняет отслеживание изменений. Всегда используйте
dispatch
. -
Игнорирование мемоизации: Отсутствие
useMemo
для значений контекста гарантированно вызовет проблемы с ререндерами в больших проектах.
Комбинация Context API и useReducer
покрывает большую часть требований к глобальному управлению состоянием в типичном приложении, сохраняя баланс между мощью и сложностью. Используйте мемоизацию, думайте о гранулярности, избегайте монолитного хранилища, и вы получите предсказуемое состояние без лишнего бойлерплейта. При правильной реализации этот паттерн масштабируется вплоть до тысяч компонентов, оставаясь удобным для сопровождения.