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.
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:
- Core concepts — state, reducer, dispatch, action
useReducerhook — inputs, outputs, wiring- Pattern definition — controlled state, custom logic injection
- Key benefits — modularity, predictability, lean components
- Examples — Toggle (default + restricted), form validation
- Pattern combinations — Context, Provider-level reducers, custom hooks
- Best practices & pitfalls — when to skip reducers
- Practical assignments — wizard, progress bar, undo/redo
Quick index
| # | Section |
|---|---|
| 1 | Core concepts |
| 2 | useReducer hook |
| 3 | Pattern definition |
| 4 | Key benefits |
| 5 | Example: Toggle component |
| 6 | Example: Form validation |
| 7 | Pattern combinations |
| 8 | Best practices & pitfalls |
| 9 | Practical assignments |
1. Core concepts
Four terms appear in every reducer-based design.
| Term | Role | Analogy |
|---|---|---|
| State | Current snapshot of component memory | The "now" of your feature |
| Reducer | Pure function that computes the next state | State change logic in one place |
| Dispatch | Function that sends an action to the reducer | The action trigger |
| Action | Object describing what happened (type + payload) | The interaction signal |
User clicks "Add to cart"
→ dispatch({ type: 'ADD', item: product })
→ reducer(currentState, action)
→ nextState
→ React re-renders with nextStateAction shape convention
type Action = { type: 'INCREMENT' } | { type: 'SET'; value: number } | { type: 'RESET' }2. useReducer hook
React's built-in hook connects state, reducer, and dispatch.
const [state, dispatch] = useReducer(reducer, initialState)| Input / output | Meaning |
|---|---|
| Reducer function | (state, action) => nextState — pure transition logic |
| Initial state | Starting snapshot (object, array, or primitive) |
| Current state | Return value [0] — read in JSX and effects |
| Dispatch function | Return value [1] — stable reference, send actions |
Lazy initialization (optional third argument)
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)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.
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
}| Idea | Meaning |
|---|---|
| Controlled state management | Single reducer governs all allowed transitions |
| Inversion of control | Component author defines defaults; consumer overrides via stateReducer |
| Custom logic injection | Restrict toggles, cap steps, log analytics — without forking the widget |
4. Key benefits
| Benefit | What you gain |
|---|---|
| Modular architecture | Transition rules live in one file — cartReducer.ts, formReducer.ts |
| Component reusability | Same Toggle with different stateReducer policies per page |
| Lean components | JSX files dispatch actions; they do not contain switch logic |
| Decoupled logic | Test reducers as pure functions without React |
| Predictable transitions | Every state change flows through named actions — auditable trail |
Before — scattered setState
function handleClick() {
if (clicks >= 5) return
setOn((o) => !o)
setClicks((c) => c + 1)
if (!on) analytics.track('enabled')
}After — reducer owns rules
function handleClick() {
dispatch({ type: 'TOGGLE' })
}
// All rules in toggleReducer — testable without DOM5. Example: Toggle component
Build a reusable Toggle with internal reducer plus optional consumer stateReducer for restricted clicks.
'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
<Toggle>
{({ on, toggle }) => (
<button type="button" aria-pressed={on} onClick={toggle}>
{on ? 'On' : 'Off'}
</button>
)}
</Toggle>Restricted Click Toggle — block after 3 toggles
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>6. Example: Form validation
Multi-field forms benefit from a reducer that tracks values, touched flags, and errors in one place.
'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>
)
}7. Pattern combinations
Reducers scale beyond a single component when paired with Context and custom hooks.
Context API for global state
'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
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 }),
}
}| Combination | When to use |
|---|---|
| Context + reducer at Provider | Global cart, theme modes with history, app-wide wizard |
| Custom hook + reducer | Feature-scoped logic reusable across pages |
| Component + stateReducer prop | Library widgets with consumer override policies |
8. Best practices & pitfalls
| Practice / pitfall | Guidance |
|---|---|
| Avoid for simple logic | Single boolean toggle in one page → useState is enough |
| Prevent over-engineering | No reducer until transitions multiply or need consumer injection |
| Reducer at Provider level | Global features: one reducer in Provider, not per leaf component |
| Default reducer parameters | Always handle unknown actions with default: return state |
| Do not mutate state | Spread or copy — never state.items.push() inside reducer |
| Keep reducers pure | No API calls — dispatch FETCH_SUCCESS from effects instead |
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.
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']> }Assignment 2 — Global progress bar
ProgressProvider at layout root; any page dispatches SET with upload percent during file transfer or RESET on complete.
// 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.
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
}
}Summary
| Piece | Role |
|---|---|
| State | Current snapshot — component memory |
| Action | Describes what happened |
| Reducer | Pure (state, action) => nextState logic |
| Dispatch | Sends actions — stable from useReducer |
| stateReducer prop | Consumer 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.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime