Для разработчиков React, растущая сложность состояния приложения — неизбежный вызов. Классический useState
отлично работает для изолированных компонентов, но при масштабировании легко превращается в запутанный клуб колбэков. Рассмотрим альтернативу, которая не требует установки Redux или MobX, но охватывает 90% потребностей: связка useReducer
и Context API.
Когда useState
перестает быть другом
Представьте компонент, управляющий корзиной покупок:
// Наивная реализация
function ShoppingCart() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const addItem = (product) => {
setIsLoading(true);
fetch('/api/cart', { method: 'POST', body: JSON.stringify(product) })
.then(() => {
const updatedItems = [...items, product];
setItems(updatedItems);
setTotal(updatedItems.reduce((sum, item) => sum + item.price, 0));
})
.finally(() => setIsLoading(false));
};
// Десятки строк подобной логики...
}
Проблемы:
- Разбросанная логика: Обновление
items
иtotal
выполняется отдельно, рискуя рассинхронизацией. - Повторяемый код: Похожие пайплайны для удаления/обновления товаров.
- Сложность тестирования: Бизнес-логика зашита в кмпоненте.
Рецепт: useReducer
как state machine
useReducer
инкапсулирует логику обновления состояния в предсказуемый конечный автомат. Перепишем пример:
// Редуктор (reducer) — чистая функция состояния
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload],
total: state.total + action.payload.price,
};
case 'REMOVE_ITEM':
const filteredItems = state.items.filter(item => item.id !== action.payload.id);
return {
...state,
items: filteredItems,
total: filteredItems.reduce((sum, item) => sum + item.price, 0),
};
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// Инициализация состояния
const initialState = {
items: [],
total: 0,
isLoading: false
};
Почему это мощнее?
- Все изменения состояния описываются в одном месте.
- Легко добавлять сайд-эффекты (например, логирование).
- Состояние всегда обновляется атомарно.
Интеграция с Context API: Глобальный доступ без пропс-дриллинга
Создадим контекст и провайдер:
const CartContext = React.createContext();
function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
// Действия (actions) инкапсулируют асинхронные операции
const addItem = async (product) => {
dispatch({ type: 'SET_LOADING', payload: true });
try {
await fetch('/api/cart', { method: 'POST', body: JSON.stringify(product) });
dispatch({ type: 'ADD_ITEM', payload: product });
} catch (error) {
console.error('Failed to add item:', error);
} finally {
dispatch({ type: 'SET_LOADING', payload: false });
}
};
const value = {
state,
actions: { addItem } // Экспортируем методы API
};
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
Теперь любой компонент может использовать корзину без передачи пропсов:
function ProductList() {
const { actions } = useContext(CartContext);
return <button onClick={() => actions.addItem(product)}>Add to Cart</button>;
}
function CartSummary() {
const { state } = useContext(CartContext);
return <div>Total: {state.total} | Items: {state.items.length}</div>;
}
Критические оптимизации
Избегайте лишних ререндеров
При изменении состояния isLoading
перерендериваются все компоненты, использующие useContext(CartContext)
, даже если их интересует только items
. Решение:
// Вместо:
const value = { state, actions };
// Разделите контекст на данные и методы:
const CartStateContext = React.createContext();
const CartActionsContext = React.createContext();
function CartProvider({ children }) {
// ... useReducer логика ...
return (
<CartStateContext.Provider value={state}>
<CartActionsContext.Provider value={actions}>
{children}
</CartActionsContext.Provider>
</CartStateContext.Provider>
);
}
// Хук для доступа к состоянию
function useCartState() {
const context = useContext(CartStateContext);
if (!context) throw new Error('Used outside CartProvider');
return context;
}
// Хук для действий
function useCartActions() {
const context = useContext(CartActionsContext);
if (!context) throw new Error('Used outside CartProvider');
return context;
}
Теперь ProductList
использует useCartActions()
и не ререндерится при изменении state.items
.
Оптимизация тяжелых вычислений
Если вычисление total
ресурсоемко, используйте мемоизацию:
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
const newItems = [...state.items, action.payload];
return {
...state,
items: newItems,
total: calculateTotal(newItems), // Вынесенная функция
};
// ...
}
}
// Мемоизация через useMemo внутри компонента тоже работает
function CartSummary() {
const { items } = useCartState();
const total = useMemo(() => items.reduce((sum, item) => sum + item.price, 0), [items]);
}
Когда переходить на Redux?
Контекст + useReducer
подходит для:
- Приложений среднего масштаба.
- Состояния, не требующего persistence или time-travel дебаггинга.
- Команд, предпочитающих "нативный" подход без зависимостей.
Redux оправдан при:
- Экстремально большом состоянии (тысячи элементов).
- Необходимости middleware (например, для обработки сложных асинхронных потоков).
- Требовании к инструментам разработчика (DevTools).
Паттерны для масштабирования
- Модульные редукторы:
Комбинируйте несколько редукторов черезcombineReducers
(аналог Redux):
function rootReducer(state, action) {
return {
cart: cartReducer(state.cart, action),
user: userReducer(state.user, action),
};
}
-
Сериализуемые действия:
Всегда передавайтеpayload
как примитивы или простые объекты. Избегайте классов или функций. -
Тестирование:
Редукторы — чистые функции. Тестируйте их изолированно:
test('ADD_ITEM updates total correctly', () => {
const state = { items: [], total: 0 };
const action = { type: 'ADD_ITEM', payload: { id: 1, price: 50 } };
expect(cartReducer(state, action).total).toBe(50);
});
Заключение
useReducer
и Context API — мощный дуэт для управления состоянием, который часто недооценивают. Его ключевые преимущества:
- Минимальный бойлерплейт по сравнению с Redux.
- Предсказуемость за счет централизованных обновлений состояния.
- Гибкость благодаря разделению на actions и state.
Используйте эту связку, когда чувствуете, что useState
начинает задыхаться под логикой компонента, но еще не готовы к погружению в мир Redux Toolkit. При грамотной оптимизации производительности, она справится с большинством реальных сценариев.
Философское послесловие: Управление состоянием — это поиск баланса между структурой и гибкостью. Иногда достаточно молотка, не тащите бетономешалку. Лучшая архитектура — та, которая решает ваши задачи сегодня, но не запрещает меняться завтра.