Context with useReducer — Structured State Updates

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
Note: 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.
Tip: Define action types as a JavaScript object (or TypeScript enum) to avoid typos: 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.
Warning: Reducer functions must be pure — no side effects, no async calls, no API requests. A reducer only transforms state. Side effects (like clearing localStorage or making an API call) must happen outside the reducer — either in the dispatch call site or in a useEffect that watches the state. If your reducer is doing anything other than computing the next state, move that logic out.

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 }

🧠 Test Yourself

Your notification reducer handles an ADD action but forgets the default case. A component dispatches an unknown action type by mistake. What happens?