Для React-разработчиков решение о структуре состояния приложения часто сводится к выбору: поднимать state до общего родителя или внедрять Redux/Zustand. Но есть третий путь, который часто упускают из виду — комбинация Context
и useReducer
. Этот тандем обеспечивает предсказуемость Flux-подобных систем без накладных расходов сторонних библиотек. Как избежать фатальных ошибок в производительности и правильно выстроить архитектуру? Перед вами — инженерное руководство.
Почему именно эта связка?
Пропс-дриллинг разрушает поддержку кода, а Redux для простых сценариев — это избыточно. Context + useReducer предлагает локализованное глобальное состояние:
- Централизованная логика обновлений через reducer
- Доступ к данным из любой части приложения
- Нулевые дополнительные зависимости
- Нативная интеграция с Concurrent Mode
Проблема в том, что наивная реализация гарантированно выстрелит в ногу производительности. Рассмотрим проблему на живом примере.
Типичная ошибка: ререндер всего подряд
Представьте классический провайдер:
const UserContext = React.createContext();
export function UserProvider({ children }) {
const [state, dispatch] = useReducer(userReducer, initialState);
return (
<UserContext.Provider value={{ state, dispatch }}>
{children}
</UserContext.Provider>
);
}
Казалось бы, элегантно. Но любой компонент, использующий useContext(UserContext)
, будет перерисовываться при каждом изменении state
— даже если ему нужна лишь маленькая часть данных. В приложении среднего размера это гарантирует лавину ререндеров.
Решение: разделяй и властвуй
Ключ к оптимизации — сегментация контекста и мемоизация. Разделим данные и операции:
// state.js
export const UserStateContext = React.createContext(null);
export const UserDispatchContext = React.createContext(null);
// provider.jsx
export function UserProvider({ children }) {
const [state, dispatch] = useReducer(userReducer, initialState);
return (
<UserStateContext.Provider value={state}>
<UserDispatchContext.Provider value={dispatch}>
{children}
</UserDispatchContext.Provider>
</UserStateContext.Provider>
);
}
// hook.js
export function useUserState() {
const context = React.useContext(UserStateContext);
if (context === undefined) throw new Error('...');
return context;
}
export function useUserDispatch() {
const context = React.useContext(UserDispatchContext);
if (context === undefined) throw new Error('...');
return context;
}
Теперь компонент, использующий только useUserDispatch()
, не будет перерисовываться при изменении состояния. Диспетчер — стабильная функция.
Архитектура редьюсера: как не превратить код в ад
Ошибка новичков — создание монолитного редьюсера на 500 строк. Решение — доменная декомпозиция:
function userReducer(state, action) {
switch (action.type) {
case 'LOGIN_SUCCESS':
return {
...state,
user: action.payload.user,
status: 'authenticated'
};
case 'FETCH_PROFILE_FAILURE':
return {
...state,
profile: null,
error: action.payload.error
};
default:
return state;
}
}
Но что если необходимы сайд-эффекты? Используйте паттерн «команд с эффектами»:
function userReducer(state, action) {
if (action.type === 'LOGIN') {
// Инициируем процесс во внешнем мире
action.callback?(state.credentials);
return { ...state, isLoading: true };
}
// ...
}
Важно: эффекты вне редьюсера (через useEffect или thunk) предпочтительнее для сохранения воспроизводимого состояния.
Контроль ререндеров: точечная выборка данных
Даже с разделённым контекстом выборка объекта целиком приводит к ререндерам при обновлении любого его поля. Решение — мемоизированные селекторы:
const selectUserProfile = state => state.profile;
function UserProfile() {
const dispatch = useUserDispatch();
const profile = useUserState(selectUserProfile); // Кастомный хук (читай ниже)
return <ProfileCard data={profile} />;
}
Реализуем оптимизированый хук:
export function useUserState(selector) {
const state = React.useContext(UserStateContext);
sel = useMemo(() => selector, [selector]);
return useMemo(() => sel(state), [state, sel]);
}
selector
выступает функцией проекции — компонент получит ререндер только если результат selector(state)
изменился (проверка через shallow equal).
Типизация на TypeScript: полная безопасность
Выведите интерфейсы автоматически из редьюсера:
type Action =
| { type: 'LOGIN'; payload: { email: string } }
| { type: 'LOGOUT'; meta?: { silent: boolean } };
type State = {
user: User | null;
error: string | null;
};
export function useUserDispatch() {
const context = useContext(UserDispatchContext);
// context имеет тип React.Dispatch<Action>
}
Такой подход исключает невалидные экшены и гарантирует корректность в рантайме.
Когда не использовать этот подход?
Стоит предусмотреть случаи, когда Context не лучший выбор:
- Высокочастотные обновления (анимации, драг-н-дроп)
- Общие данные между микросервисными частями приложения
- Состояние со сложными inter-dependencies
Для этих сценариев присмотритесь к Zustand, Jotai или Recoil.
Итоговый инжиниринговый чеклист
- Физическое разделение State/Dispatch контекстов – защита от «случайных» ререндеров.
- Доменный редьюсер – никакой бизнес-логики кроме преобразования (state, action) => nextState.
- Селекторы с мемоизацией – используйте с автономными селекторными функциями.
- Строгие границы – один контекст на домен, не создавайте God Context.
- Статическая типизация – редкий случай, когда TypeScript оправдан на 200%.
Этот метод не серебряная пуля, но для большинства приложений средней сложности Context + useReducer покрывает все требования к состоянию с минимальным оверхедом. Пример выше масштабируется до уровня Airbnb при правильном зонировании модулей. Забудьте пропс-дриллинг и переусложнённые нарративы — иногда достаточно встроенных инструментов.