As a context grows beyond two or three state variables, managing updates with multiple useState calls becomes harder to reason about — especially when multiple state values must change together in response to a single event. useReducer solves this by centralising all state transitions into a single pure function: a reducer. The reducer takes the current state and an action object, and returns the next state. For context providers with complex update logic — like a notification system or a multi-step auth flow — useReducer makes state transitions explicit, testable, and predictable.
useState vs useReducer in Context
| Multiple useState | useReducer | |
|---|---|---|
| State structure | Multiple separate variables | Single state object |
| Update logic | Scattered across handlers | Centralised in one reducer function |
| Derived state | Computed in multiple places | Computed once from the single state object |
| Testability | Must test through React rendering | Reducer is a pure function — test directly |
| Best for | Simple, independent state variables | Related state that changes together, complex transitions |
useReducer does not replace Context — it replaces the state management mechanism inside a Context Provider. The Provider still creates the context, holds the state, and exposes it to consumers. The difference is that the state is now managed by a reducer function instead of multiple useState calls. Consumers interact with it the same way — they just call dispatch actions instead of calling setter functions directly.const AUTH_ACTIONS = { SET_USER: 'SET_USER', LOGOUT: 'LOGOUT' }. Dispatching { type: AUTH_ACTIONS.LOGOUT } is refactor-safe — renaming the constant updates all dispatch calls automatically. Dispatching raw strings like { type: 'LOGOUT' } invites silent bugs when strings are mistyped.Notification Context with useReducer
// src/context/NotificationContext.jsx
import { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
// ── Action types ──────────────────────────────────────────────────────────────
const ACTIONS = {
ADD: 'ADD_NOTIFICATION',
DISMISS: 'DISMISS_NOTIFICATION',
CLEAR: 'CLEAR_ALL',
};
// ── Initial state ─────────────────────────────────────────────────────────────
const initialState = {
notifications: [], // [{ id, type, message, duration }]
};
// ── Reducer — pure function: (state, action) → nextState ─────────────────────
function notificationReducer(state, action) {
switch (action.type) {
case ACTIONS.ADD:
return {
...state,
notifications: [
...state.notifications,
{
id: Date.now(), // simple unique ID
type: action.payload.type || 'info', // 'success' | 'error' | 'info'
message: action.payload.message,
duration: action.payload.duration || 4000, // ms before auto-dismiss
},
],
};
case ACTIONS.DISMISS:
return {
...state,
notifications: state.notifications.filter(n => n.id !== action.payload.id),
};
case ACTIONS.CLEAR:
return { ...state, notifications: [] };
default:
return state; // unknown action — return unchanged state
}
}
// ── Context ───────────────────────────────────────────────────────────────────
const NotificationContext = createContext(undefined);
export function NotificationProvider({ children }) {
const [state, dispatch] = useReducer(notificationReducer, initialState);
// ── Action creators — wrap dispatch calls in stable callbacks ─────────────
const addNotification = useCallback((message, type = 'info', duration = 4000) => {
dispatch({ type: ACTIONS.ADD, payload: { message, type, duration } });
}, []);
const dismissNotification = useCallback((id) => {
dispatch({ type: ACTIONS.DISMISS, payload: { id } });
}, []);
const clearAll = useCallback(() => {
dispatch({ type: ACTIONS.CLEAR });
}, []);
// ── Convenience shortcuts ──────────────────────────────────────────────────
const notify = useMemo(() => ({
success: (msg, dur) => addNotification(msg, 'success', dur),
error: (msg, dur) => addNotification(msg, 'error', dur),
info: (msg, dur) => addNotification(msg, 'info', dur),
}), [addNotification]);
const value = useMemo(() => ({
notifications: state.notifications,
addNotification,
dismissNotification,
clearAll,
notify,
}), [state.notifications, addNotification, dismissNotification, clearAll, notify]);
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
}
export function useNotifications() {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error('useNotifications must be inside NotificationProvider');
return ctx;
}
The Notification Banner Component
// src/components/ui/NotificationBanner.jsx
import { useEffect } from 'react';
import { useNotifications } from '@/context/NotificationContext';
function NotificationItem({ notification }) {
const { dismissNotification } = useNotifications();
// Auto-dismiss after duration
useEffect(() => {
const timer = setTimeout(() => {
dismissNotification(notification.id);
}, notification.duration);
return () => clearTimeout(timer); // cancel if dismissed manually first
}, [notification.id, notification.duration, dismissNotification]);
return (
<div className={`notification notification--${notification.type}`}>
<p>{notification.message}</p>
<button
onClick={() => dismissNotification(notification.id)}
aria-label="Dismiss notification"
>
×
</button>
</div>
);
}
function NotificationBanner() {
const { notifications } = useNotifications();
if (notifications.length === 0) return null;
return (
<div className="notification-banner" role="status" aria-live="polite">
{notifications.map(n => (
<NotificationItem key={n.id} notification={n} />
))}
</div>
);
}
export default NotificationBanner;
// ── Using the notification system ──────────────────────────────────────────────
function CreatePostPage() {
const { notify } = useNotifications();
const handleSubmit = async () => {
try {
await postService.create(formData);
notify.success('Post published successfully!');
navigate('/dashboard');
} catch {
notify.error('Failed to publish post. Please try again.');
}
};
}
Testing the Reducer in Isolation
// Pure function — no React needed, test directly with plain JS
import { notificationReducer } from './NotificationContext';
const ACTIONS = { ADD: 'ADD_NOTIFICATION', DISMISS: 'DISMISS_NOTIFICATION' };
// Test ADD action
const state1 = notificationReducer(
{ notifications: [] },
{ type: ACTIONS.ADD, payload: { message: 'Saved!', type: 'success', duration: 3000 } }
);
console.assert(state1.notifications.length === 1, 'Should have one notification');
console.assert(state1.notifications[0].message === 'Saved!', 'Message should match');
// Test DISMISS action
const state2 = notificationReducer(state1, {
type: ACTIONS.DISMISS,
payload: { id: state1.notifications[0].id },
});
console.assert(state2.notifications.length === 0, 'Should be empty after dismiss');
Common Mistakes
Mistake 1 — Mutating state in the reducer
❌ Wrong — push mutates state directly:
case ACTIONS.ADD:
state.notifications.push(action.payload); // MUTATES state — React won't detect change!
return state;
✅ Correct — always return a new object/array:
case ACTIONS.ADD:
return { ...state, notifications: [...state.notifications, action.payload] }; // ✓
Mistake 2 — Putting async logic in the reducer
❌ Wrong — reducer makes an API call:
case ACTIONS.LOGIN:
const res = await api.post('/auth/login', action.payload); // async in reducer — WRONG
return { ...state, user: res.data.user };
✅ Correct — async logic in the dispatch call site, reducer only handles state:
const login = async (email, password) => {
const res = await api.post('/auth/login', { email, password }); // async outside reducer
dispatch({ type: ACTIONS.SET_USER, payload: res.data.data }); // ✓ dispatch pure action
};
Mistake 3 — Missing the default case in the reducer
❌ Wrong — no default case returns undefined:
function reducer(state, action) {
switch (action.type) {
case 'ADD': return { ...state, ... };
// No default! → Returns undefined for unknown actions → state becomes undefined
}
}
✅ Correct — always return state for unrecognised actions:
default: return state; // ✓ unknown action → unchanged state
Quick Reference
| Concept | Code |
|---|---|
| Declare reducer | function reducer(state, action) { switch(action.type) { ... } } |
| Use in Provider | const [state, dispatch] = useReducer(reducer, initialState) |
| Dispatch action | dispatch({ type: ACTIONS.ADD, payload: data }) |
| Action creator | const addItem = useCallback((data) => dispatch({ type: ACTIONS.ADD, payload: data }), []) |
| Always return state | default: return state in reducer |
| Immutable update | return { ...state, field: newValue } |