Context + Reducer Pattern
Predictable state management for React applications without external libraries.
Problem
Complex React applications need shared state across many components. Props drilling becomes unwieldy, but full state management libraries add complexity.
Solution
Combine React Context with useReducer for predictable state updates:
import { createContext, useContext, useReducer, ReactNode } from 'react';
// 1. Define state shape
interface AppState {
items: Item[];
filter: 'all' | 'active' | 'completed';
loading: boolean;
}
const initialState: AppState = {
items: [],
filter: 'all',
loading: false
};
// 2. Define actions with discriminated unions
type AppAction =
| { type: 'ADD_ITEM'; payload: Item }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'SET_FILTER'; payload: AppState['filter'] }
| { type: 'SET_LOADING'; payload: boolean };
// 3. Create reducer
function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] };
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(i => i.id !== action.payload)
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'SET_LOADING':
return { ...state, loading: action.payload };
default:
return state;
}
}
// 4. Create context
interface AppContextValue {
state: AppState;
dispatch: React.Dispatch<AppAction>;
}
const AppContext = createContext<AppContextValue | null>(null);
// 5. Create provider
export function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
}
// 6. Create hook for consuming
export function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within AppProvider');
}
return context;
}Usage
In Components
function ItemList() {
const { state, dispatch } = useApp();
const filteredItems = state.items.filter(item => {
if (state.filter === 'all') return true;
if (state.filter === 'active') return !item.completed;
return item.completed;
});
return (
<ul>
{filteredItems.map(item => (
<li key={item.id}>
{item.title}
<button
onClick={() =>
dispatch({ type: 'REMOVE_ITEM', payload: item.id })
}
>
Remove
</button>
</li>
))}
</ul>
);
}Action Creators (Optional)
// For complex actions, create helper functions
const actions = {
addItem: (item: Item): AppAction => ({
type: 'ADD_ITEM',
payload: item,
}),
removeItem: (id: string): AppAction => ({
type: 'REMOVE_ITEM',
payload: id,
}),
};
// Usage
dispatch(actions.addItem(newItem));Testing
import { renderHook, act } from '@testing-library/react';
describe('AppContext', () => {
it('adds items', () => {
const { result } = renderHook(() => useApp(), {
wrapper: AppProvider,
});
act(() => {
result.current.dispatch({
type: 'ADD_ITEM',
payload: { id: '1', title: 'Test' },
});
});
expect(result.current.state.items).toHaveLength(1);
});
});Trade-offs
When to Use
- Medium complexity apps
- Shared state across 3+ components
- Need for predictable state updates
- Want to avoid external dependencies
When NOT to Use
- Simple apps (useState is enough)
- Very complex apps (consider Zustand/Redux)
- Performance-critical with frequent updates
Source
Pattern used in GTD and other React projects for manageable state.