Разработчики React постоянно сталкиваются с дилеммой выбора решения для управления состоянием. Пропс-дриллинг превращает компоненты в лабиринт передачи данных, а внешние решения вроде Redux могут быть избыточными для небольших проектов. Пора разобрать альтернативу, встроенную прямо в React — комбинацию Context API и useReducer.
Проблема: когда состояние выходит из-под контроля
Представьте:
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
return (
<Header user={user} theme={theme} setTheme={setTheme} />
<Dashboard
user={user}
theme={theme}
notifications={notifications}
setNotifications={setNotifications}
/>
<Sidebar
user={user}
theme={theme}
setTheme={setTheme}
/>
);
}
Каждый новый элемент состояния раздувает интерфейсы компонентов. Изменение структуры состояния требует правки десятков компонентов. Производительность страдает от лишних ререндеров — классический пропс-дриллинг работает по принципу "затронул один — обнови всех".
Решение на борту: Context + Reducer
1. Создаем контекст по правилам
Контекст должен:
- Инкапсулировать логику предметной области
- Минимизировать повторные рендеры
- Обеспечивать предсказуемую структуру
Для примера возьмем аутентификацию:
// contexts/AuthContext.js
import { createContext, useContext, useMemo, useReducer } from 'react';
const AuthContext = createContext();
const initialState = {
user: null,
loading: false,
error: null,
isAuthenticated: false
};
function reducer(state, action) {
switch (action.type) {
case 'LOGIN_REQUEST':
return { ...state, loading: true };
case 'LOGIN_SUCCESS':
return {
...state,
user: action.payload,
loading: false,
isAuthenticated: true
};
case 'LOGIN_FAILURE':
return { ...state, error: action.error, loading: false };
case 'LOGOUT':
return { ...initialState };
case 'EDIT_PROFILE':
return { ...state, user: { ...state.user, ...action.payload } };
default:
throw new Error(`Unhanded action type: ${action.type}`);
}
}
2. Оптимизируем провайдер
Ключевой провайдер должен:
- Предотвращать лишние ререндеры
- Предоставлять стабильные ссылки на функции
- Обрабатывать побочные эффекты
export function AuthProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
const actions = useMemo(() => ({
login: async (credentials) => {
dispatch({ type: 'LOGIN_REQUEST' });
try {
const user = await api.login(credentials);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'LOGIN_FAILURE', error });
throw error; // Пробрасываем дальше для обработки в UI
}
},
logout: () => {
dispatch({ type: 'LOGOUT' });
api.cleanupSession();
},
updateProfile: (data) => {
dispatch({ type: 'EDIT_PROFILE', payload: data });
}
}), []);
const value = useMemo(() => ({
...state,
actions
}), [state, actions]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
Обратите внимание:
useMemo
стабилизирует объект действий- Состояние и действия разделены
- Ошибки пробрасываются на уровень UI
3. Используем в компонентах
function LoginForm() {
const { loading, error, actions } = useAuth();
const [formData, setFormData] = useState({ email: '', password: '' });
const handleSubmit = async (e) => {
e.preventDefault();
try {
await actions.login(formData);
// Перенаправление после успеха
} catch {
// Ошибка уже обновлена в контексте
}
};
return (
<form onSubmit={handleSubmit}>
{error && <ErrorBanner message={error.message} />}
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({...formData, email: e.target.value})}
/>
<input
type="password"
value={formData.password}
onChange={/* аналогично */}
/>
<button disabled={loading}>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
);
}
4. Решаем главную проблему: ререндеры
Почему после логина компонент Navbar
(который использует только имя пользователя) самопроизвольно рендерится при изменении счётчика уведомлений? Потому что они в одном контексте!
Решение — разбить контексты по областям:
// Оптимизированная структура контекстов
export function AppProviders({ children }) {
return (
<AuthProvider>
<NotificationsProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</NotificationsProvider>
</AuthProvider>
);
}
Каждый провайдер отвечает за свою доменную область. Разделение снижает частоту ререндеров и улучшает производительность на 20-40% в среднестатистическом приложении.
5. Тесты: гарантии стабильности
Хуки контекста прекрасно тестируются с помощью Jest и React Testing Library:
test('login updates auth state', async () => {
const TestComponent = () => {
const { user, isAuthenticated } = useAuth();
return (
<div>
<span data-testid="user">{user?.name}</span>
<span data-testid="status">{isAuthenticated.toString()}</span>
</div>
);
};
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
// Начальное состояние
expect(screen.getByTestId('status').textContent).toBe('false');
// Выполняем действие
const { actions } = renderHook(() => useAuth()).result.current;
await actions.login({ email: 'test@test.com', password: 'secure' });
// Проверяем изменения
expect(screen.getByTestId('user').textContent).toBe('Alex');
expect(screen.getByTestId('status').textContent).toBe('true');
});
Когда Context API недостаточно? Сигналы тревоги
Рассмотрите Redux Toolkit если:
- Вы оперируете сложными взаимосвязями между состоянием (EntityAdapter)
- Нужна визуализация состояния при дебаггинге (Redux DevTools)
- Пакетная обработка обновлений для критичных по производительности участков
- Интеграция с middleware (сетевые запросы, кеширование)
- Потребность в шаблонных способах обновления состояния через полный набор плагинов
- Нужны кеширующие решения для запросов — RTK Query проявит себя лучше, чем кастомные велосипеды.
Для 90% приложений Context API с грамотной архитектурой прекрасно заменяет Redux. В остальные 10% входят высокочастотные UI (диаграммы, графики), серьёзные бизнес-системы с сложными валидациями и крупные проекты с большой командой разработчиков.
Пилуйте дерево компонентов и состояние отдельно
- Дифференцируйте контексты по частоте изменений — авторизация, нотификации и темы существуют в разных временных измерениях.
- Инжектируйте действия через контекст, а не импортируйте напрямую — упрощает тестирование и SSR.
- Трансформации данных держите в редьюсерах — компоненты не должны включать логику расчётов изменений.
- Стабилизируйте ссылки тщательно —
useMemo
,useCallback
для объектов контекста. - Типизировать обязательно — TypeScript interface для контекста устранит большинство ошибок сопоставления.
Для проверки гипотезы эффективности контекста включите в DevTools подсчёт ререндеров при профилировании. Оптимальная работа достигается при частоте обновлений не более чем на глубину трёх зависимостей.
Итого: разгружайте компоненты, а не философию
Context API и useReducer предложены не потому что "React не хватает хранилища", а потому что большинство библиотек не способны корректно интегрироваться с конкурентным рендерингом и Suspense. Нативные решения сегодня показывают наибольшую предсказуемость в новых фичах React.
Пример из практики: приложение с 500 000 строк кода, где 70% состояния управлялось через Context API (40+ контекстов), потребовало внедрения Redux на 25% при изменении спецификации в сторону внедрения реактивных обновлений. Масштабируйте с долгосрочной перспективой.