Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactDesign PatternsuseReducerState ManagementComponent Patterns

State Reducer Pattern in React

Master React useReducer and the state reducer pattern — actions, dispatch, inversion of control, Toggle and form validation examples, Context combos, undo/redo, and when not to over-engineer.

Jul 4, 202615 min read

Introduction

The State Reducer pattern moves state transition logic into a pure reducer function — (state, action) => nextState — instead of scattering setState calls across event handlers. React's useReducer hook powers local features; the inversion-of-control variant lets consumers inject custom transition rules into reusable components (Toggle, Select, Combobox).

This guide covers:

  1. Core concepts — state, reducer, dispatch, action
  2. useReducer hook — inputs, outputs, wiring
  3. Pattern definition — controlled state, custom logic injection
  4. Key benefits — modularity, predictability, lean components
  5. Examples — Toggle (default + restricted), form validation
  6. Pattern combinations — Context, Provider-level reducers, custom hooks
  7. Best practices & pitfalls — when to skip reducers
  8. Practical assignments — wizard, progress bar, undo/redo

Note

useReducer and the state reducer prop pattern solve related problems. useReducer centralizes logic for your feature; the state reducer prop lets consumers override transitions inside your library component without forking source code.

Quick index

#Section
1Core concepts
2useReducer hook
3Pattern definition
4Key benefits
5Example: Toggle component
6Example: Form validation
7Pattern combinations
8Best practices & pitfalls
9Practical assignments

1. Core concepts

Four terms appear in every reducer-based design.

TermRoleAnalogy
StateCurrent snapshot of component memoryThe "now" of your feature
ReducerPure function that computes the next stateState change logic in one place
DispatchFunction that sends an action to the reducerThe action trigger
ActionObject describing what happened (type + payload)The interaction signal
plaintext
User clicks "Add to cart"
  → dispatch({ type: 'ADD', item: product })
  → reducer(currentState, action)
  → nextState
  → React re-renders with nextState

Action shape convention

tsx
type Action = { type: 'INCREMENT' } | { type: 'SET'; value: number } | { type: 'RESET' }

Note

Reducers must be pure — no fetch, no Date.now() side effects, no mutating state in place. Side effects belong in event handlers or useEffect, which dispatch actions when results arrive.

Tip

Name actions as past-tense events (ITEM_ADDED, STEP_COMPLETED) or imperative commands (ADD_ITEM, GO_NEXT) — pick one convention per codebase and stay consistent.

Back to index


2. useReducer hook

React's built-in hook connects state, reducer, and dispatch.

tsx
const [state, dispatch] = useReducer(reducer, initialState)
Input / outputMeaning
Reducer function(state, action) => nextState — pure transition logic
Initial stateStarting snapshot (object, array, or primitive)
Current stateReturn value [0] — read in JSX and effects
Dispatch functionReturn value [1] — stable reference, send actions

Lazy initialization (optional third argument)

tsx
function init(initialCount: number) {
  return { count: initialCount, history: [] as number[] }
}
 
function counterReducer(state: ReturnType<typeof init>, action: { type: 'inc' | 'dec' }) {
  switch (action.type) {
    case 'inc':
      return { count: state.count + 1, history: [...state.history, state.count + 1] }
    case 'dec':
      return { count: state.count - 1, history: [...state.history, state.count - 1] }
    default:
      return state
  }
}
 
const [state, dispatch] = useReducer(counterReducer, 0, init)

Performance

dispatch identity is stable across renders — safe to pass to memoized children without wrapping in useCallback. Prefer useReducer over multiple useState calls when updates frequently depend on previous state in non-trivial ways.

Warning

Always include a default case that returns state unchanged. TypeScript discriminated unions help exhaustiveness, but runtime unknown actions should not crash or return undefined.

Back to index


3. Pattern definition

Two layers define the State Reducer pattern in React applications.

Layer 1 — Feature reducer with useReducer

Controlled state management: one reducer owns all transitions for a feature (cart, wizard, form). Event handlers only dispatch — they do not compute next state inline.

Layer 2 — Inversion of control (library pattern)

Reusable components (Toggle, Tabs, Typeahead) accept an optional stateReducer prop so consumers inject custom logic without rewriting the component.

tsx
type ToggleState = { on: boolean; clickCount: number }
type ToggleAction = { type: 'TOGGLE' }
 
type StateReducer<S, A> = (state: S, action: A) => S
 
function defaultToggleReducer(state: ToggleState, action: ToggleAction): ToggleState {
  if (action.type === 'TOGGLE') {
    return { on: !state.on, clickCount: state.clickCount + 1 }
  }
  return state
}
IdeaMeaning
Controlled state managementSingle reducer governs all allowed transitions
Inversion of controlComponent author defines defaults; consumer overrides via stateReducer
Custom logic injectionRestrict toggles, cap steps, log analytics — without forking the widget

Note

This is the pattern popularized by Downshift and Reach UI: internal reducer runs first, then optional consumer stateReducer can allow, block, or transform the proposed next state.

Back to index


4. Key benefits

BenefitWhat you gain
Modular architectureTransition rules live in one file — cartReducer.ts, formReducer.ts
Component reusabilitySame Toggle with different stateReducer policies per page
Lean componentsJSX files dispatch actions; they do not contain switch logic
Decoupled logicTest reducers as pure functions without React
Predictable transitionsEvery state change flows through named actions — auditable trail

Before — scattered setState

tsx
function handleClick() {
  if (clicks >= 5) return
  setOn((o) => !o)
  setClicks((c) => c + 1)
  if (!on) analytics.track('enabled')
}

After — reducer owns rules

tsx
function handleClick() {
  dispatch({ type: 'TOGGLE' })
}
 
// All rules in toggleReducer — testable without DOM

Tip

Reducer transitions are easy to unit test: expect(reducer(state, action)).toEqual(expected) — no @testing-library/react required for logic coverage.

Interview Answer

"useReducer vs useState?"

Use useState for independent primitive fields (one boolean, one string). Use useReducer when multiple fields update together, the next state depends on complex previous state, or you need a log of actions (undo, wizard). Use the state reducer prop when building reusable components that must support consumer-specific transition policies.

Back to index


5. Example: Toggle component

Build a reusable Toggle with internal reducer plus optional consumer stateReducer for restricted clicks.

tsx
'use client'
 
import { useReducer, type ReactNode } from 'react'
 
type ToggleState = { on: boolean; clickCount: number }
type ToggleAction = { type: 'TOGGLE' }
 
type StateReducer<S, A> = (state: S, action: A) => S
 
function defaultToggleReducer(state: ToggleState, action: ToggleAction): ToggleState {
  switch (action.type) {
    case 'TOGGLE':
      return { on: !state.on, clickCount: state.clickCount + 1 }
    default:
      return state
  }
}
 
function combineReducers<S, A>(internal: StateReducer<S, A>, external?: StateReducer<S, A>): StateReducer<S, A> {
  return (state, action) => {
    const next = internal(state, action)
    return external ? external(next, action) : next
  }
}
 
type ToggleProps = {
  initialOn?: boolean
  stateReducer?: StateReducer<ToggleState, ToggleAction>
  children: (props: { on: boolean; toggle: () => void; clickCount: number }) => ReactNode
}
 
export function Toggle({ initialOn = false, stateReducer, children }: ToggleProps) {
  const reducer = combineReducers(defaultToggleReducer, stateReducer)
 
  const [state, dispatch] = useReducer(reducer, { on: initialOn, clickCount: 0 })
 
  const toggle = () => dispatch({ type: 'TOGGLE' })
 
  return <>{children({ on: state.on, toggle, clickCount: state.clickCount })}</>
}

Default Toggle — no restrictions

tsx
<Toggle>
  {({ on, toggle }) => (
    <button type="button" aria-pressed={on} onClick={toggle}>
      {on ? 'On' : 'Off'}
    </button>
  )}
</Toggle>

Restricted Click Toggle — block after 3 toggles

tsx
const restrictAfterThree: StateReducer<ToggleState, ToggleAction> = (state, action) => {
  if (action.type === 'TOGGLE' && state.clickCount >= 3) {
    // Block transition — return previous effective state fields
    return { ...state, on: state.on }
  }
  return state
}
 
;<Toggle stateReducer={restrictAfterThree}>
  {({ on, toggle, clickCount }) => (
    <div>
      <button type="button" aria-pressed={on} onClick={toggle} disabled={clickCount >= 3}>
        {on ? 'On' : 'Off'}
      </button>
      <p className="text-sm text-neutral-500">{3 - clickCount} toggles remaining</p>
    </div>
  )}
</Toggle>

Warning

When blocking a transition in stateReducer, return a state that preserves the intended fields — do not return partial objects missing required keys.

Back to index


6. Example: Form validation

Multi-field forms benefit from a reducer that tracks values, touched flags, and errors in one place.

tsx
'use client'
 
import { useReducer } from 'react'
 
type FormState = {
  name: string
  email: string
  errors: { name?: string; email?: string }
  touched: { name: boolean; email: boolean }
}
 
type FormAction =
  | { type: 'SET_FIELD'; field: 'name' | 'email'; value: string }
  | { type: 'BLUR'; field: 'name' | 'email' }
  | { type: 'SUBMIT' }
 
const initialFormState: FormState = {
  name: '',
  email: '',
  errors: {},
  touched: { name: false, email: false },
}
 
function validateName(name: string): string | undefined {
  if (name.trim().length < 2) return 'Name must be at least 2 characters'
  if (!/^[a-zA-Z\s'-]+$/.test(name)) return 'Name contains invalid characters'
  return undefined
}
 
function validateEmail(email: string): string | undefined {
  if (!email.includes('@')) return 'Enter a valid email address'
  if (!email.endsWith('.com') && !email.endsWith('.io')) return 'Use a .com or .io domain'
  return undefined
}
 
function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'SET_FIELD': {
      const next = { ...state, [action.field]: action.value }
      const error = action.field === 'name' ? validateName(action.value) : validateEmail(action.value)
      return {
        ...next,
        errors: { ...next.errors, [action.field]: error },
      }
    }
    case 'BLUR':
      return {
        ...state,
        touched: { ...state.touched, [action.field]: true },
      }
    case 'SUBMIT': {
      const nameError = validateName(state.name)
      const emailError = validateEmail(state.email)
      return {
        ...state,
        errors: { name: nameError, email: emailError },
        touched: { name: true, email: true },
      }
    }
    default:
      return state
  }
}
 
export function SignupForm() {
  const [state, dispatch] = useReducer(formReducer, initialFormState)
 
  const canSubmit = !state.errors.name && !state.errors.email && state.name && state.email
 
  function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    dispatch({ type: 'SUBMIT' })
    if (canSubmit) {
      // post to server…
    }
  }
 
  return (
    <form onSubmit={handleSubmit} className="flex flex-col gap-4">
      <label>
        Name
        <input
          value={state.name}
          onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'name', value: e.target.value })}
          onBlur={() => dispatch({ type: 'BLUR', field: 'name' })}
        />
        {state.touched.name && state.errors.name && <span className="text-sm text-red-600">{state.errors.name}</span>}
      </label>
 
      <label>
        Email
        <input
          type="email"
          value={state.email}
          onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'email', value: e.target.value })}
          onBlur={() => dispatch({ type: 'BLUR', field: 'email' })}
        />
        {state.touched.email && state.errors.email && (
          <span className="text-sm text-red-600">{state.errors.email}</span>
        )}
      </label>
 
      <button type="submit" disabled={!canSubmit}>
        Sign up
      </button>
    </form>
  )
}

Tip

Extract validateName and validateEmail as standalone functions — the reducer stays readable and validators are reusable on the server for matching client/server rules.

Note

For large production forms, libraries like React Hook Form or Conform handle validation ergonomics. Use a hand-rolled form reducer when teaching the pattern or when wizard/step logic and validation are tightly coupled in one state object.

Back to index


7. Pattern combinations

Reducers scale beyond a single component when paired with Context and custom hooks.

Context API for global state

tsx
'use client'
 
import { createContext, useContext, useReducer, type ReactNode } from 'react'
 
type ProgressState = { percent: number; label: string }
type ProgressAction = { type: 'SET'; percent: number; label: string } | { type: 'RESET' }
 
function progressReducer(state: ProgressState, action: ProgressAction): ProgressState {
  switch (action.type) {
    case 'SET':
      return { percent: action.percent, label: action.label }
    case 'RESET':
      return { percent: 0, label: '' }
    default:
      return state
  }
}
 
const ProgressContext = createContext<{
  state: ProgressState
  dispatch: React.Dispatch<ProgressAction>
} | null>(null)
 
export function ProgressProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(progressReducer, { percent: 0, label: '' })
 
  return <ProgressContext.Provider value={{ state, dispatch }}>{children}</ProgressContext.Provider>
}
 
export function useProgress() {
  const ctx = useContext(ProgressContext)
  if (!ctx) throw new Error('useProgress requires ProgressProvider')
  return ctx
}

Custom hook wrapping the reducer

tsx
function useWizard(steps: string[]) {
  type WizardState = { stepIndex: number }
  type WizardAction = { type: 'NEXT' } | { type: 'BACK' } | { type: 'GOTO'; index: number }
 
  function wizardReducer(state: WizardState, action: WizardAction): WizardState {
    switch (action.type) {
      case 'NEXT':
        return { stepIndex: Math.min(state.stepIndex + 1, steps.length - 1) }
      case 'BACK':
        return { stepIndex: Math.max(state.stepIndex - 1, 0) }
      case 'GOTO':
        return { stepIndex: action.index }
      default:
        return state
    }
  }
 
  const [state, dispatch] = useReducer(wizardReducer, { stepIndex: 0 })
 
  return {
    currentStep: steps[state.stepIndex],
    stepIndex: state.stepIndex,
    isFirst: state.stepIndex === 0,
    isLast: state.stepIndex === steps.length - 1,
    next: () => dispatch({ type: 'NEXT' }),
    back: () => dispatch({ type: 'BACK' }),
    goTo: (index: number) => dispatch({ type: 'GOTO', index }),
  }
}
CombinationWhen to use
Context + reducer at ProviderGlobal cart, theme modes with history, app-wide wizard
Custom hook + reducerFeature-scoped logic reusable across pages
Component + stateReducer propLibrary widgets with consumer override policies

Performance

Keep the reducer at the Provider level and expose dispatch or semantic methods (addItem, nextStep) — not raw state setters. Consumers subscribe to context; expensive subtrees can memoize on selected fields.

Back to index


8. Best practices & pitfalls

Practice / pitfallGuidance
Avoid for simple logicSingle boolean toggle in one page → useState is enough
Prevent over-engineeringNo reducer until transitions multiply or need consumer injection
Reducer at Provider levelGlobal features: one reducer in Provider, not per leaf component
Default reducer parametersAlways handle unknown actions with default: return state
Do not mutate stateSpread or copy — never state.items.push() inside reducer
Keep reducers pureNo API calls — dispatch FETCH_SUCCESS from effects instead

Warning

Do not reach for useReducer because Redux uses reducers. If your component has one useState and one handler, a reducer adds ceremony without clarity.

Warning

Putting async logic inside the reducer breaks purity and makes testing harder. Fetch in the handler, then dispatch({ type: 'LOAD_SUCCESS', data }).

Tip

Production checklist

  • Discriminated union for action types
  • Pure reducer — no side effects
  • default case returns current state
  • Extract reducers to testable modules
  • Provider-level reducer for shared features
  • Custom hook facade for ergonomic API
  • stateReducer prop only on reusable library components
  • Reach for Zustand/Redux when cross-tab persistence or middleware is needed

Back to index


9. Practical assignments

Three exercises that cement the pattern — implement each with useReducer before reaching for libraries.

Assignment 1 — Form wizard component

Multi-step signup: personal → address → review. State tracks stepIndex, field values per step, and completed flags.

tsx
type WizardState = {
  step: 0 | 1 | 2
  personal: { name: string; email: string }
  address: { city: string; zip: string }
  completed: [boolean, boolean, boolean]
}
 
type WizardAction =
  | { type: 'NEXT' }
  | { type: 'BACK' }
  | { type: 'UPDATE_PERSONAL'; payload: Partial<WizardState['personal']> }
  | { type: 'UPDATE_ADDRESS'; payload: Partial<WizardState['address']> }

Tip

Validate the current step on NEXT — dispatch STEP_INVALID or block in the reducer when required fields are empty.

Assignment 2 — Global progress bar

ProgressProvider at layout root; any page dispatches SET with upload percent during file transfer or RESET on complete.

tsx
// UploadPage.tsx
const { dispatch } = useProgress()
 
async function upload(file: File) {
  dispatch({ type: 'SET', percent: 0, label: 'Uploading…' })
  await uploadWithProgress(file, (pct) => dispatch({ type: 'SET', percent: pct, label: 'Uploading…' }))
  dispatch({ type: 'RESET' })
}

Assignment 3 — Undo / redo operations

Store a history stack in reducer state — classic pattern for editors and drawing apps.

tsx
type EditorState = {
  present: string
  past: string[]
  future: string[]
}
 
type EditorAction = { type: 'EDIT'; text: string } | { type: 'UNDO' } | { type: 'REDO' }
 
function editorReducer(state: EditorState, action: EditorAction): EditorState {
  switch (action.type) {
    case 'EDIT':
      return {
        present: action.text,
        past: [...state.past, state.present],
        future: [],
      }
    case 'UNDO':
      if (state.past.length === 0) return state
      return {
        present: state.past[state.past.length - 1],
        past: state.past.slice(0, -1),
        future: [state.present, ...state.future],
      }
    case 'REDO':
      if (state.future.length === 0) return state
      return {
        present: state.future[0],
        past: [...state.past, state.present],
        future: state.future.slice(1),
      }
    default:
      return state
  }
}

Note

Cap past array length (e.g. 50 entries) in the EDIT case to avoid unbounded memory in long editing sessions.

Interview Answer

"How is undo/redo different from just keeping previous state in a ref?"

Undo/redo requires a stack of snapshots and branch clearing — editing after undo discards the forward future stack. A reducer makes those transitions explicit and testable; a ref-only approach tends to sprawl across handlers.

Back to index


Summary

PieceRole
StateCurrent snapshot — component memory
ActionDescribes what happened
ReducerPure (state, action) => nextState logic
DispatchSends actions — stable from useReducer
stateReducer propConsumer override for library components

The State Reducer pattern centralizes transition logic, keeps components lean, and enables inversion of control for reusable widgets. Start with useReducer for multi-step forms and wizards; add the stateReducer prop when shipping components others must customize.

Next reads: Provider Pattern for Context + Provider-level reducers, Compound Components Pattern for shared implicit state, and React Design Patterns for the full pattern roadmap.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime