Field note / react

publishedupdated 2026-05-04#react#reducers#state-machine#workflows#ui-architecture

Reducers and State Machines in React

When I use reducers, when I model screens as state machines, and how explicit transitions prevent expensive UI workflow bugs.

TL;DR

  • Reducers are my default upgrade when component state starts behaving like a workflow.
  • State machines are worth it when the allowed transitions matter as much as the values.
  • The biggest benefit is not abstraction elegance; it is preventing invalid UI states.
  • If a screen has retries, partial failures, role-based branches, or async steps, explicit transitions usually save time later.

Context

A lot of React state begins simple and then quietly becomes workflow state:

  • an onboarding flow
  • a checkout path
  • a bulk approval tool
  • a modal with validation, retries, and submission

Once the screen has enough branching, useState stops being descriptive enough. The question shifts from “what values do I hold?” to “what transitions are valid?”

The Approach

1. Use a reducer when many events affect the same state

type SaveState = {
  status: 'editing' | 'submitting' | 'success' | 'failure'
  error: string | null
}

type Action =
  | { type: 'submit' }
  | { type: 'saved' }
  | { type: 'failed'; message: string }
  | { type: 'retry' }

function saveReducer(state: SaveState, action: Action): SaveState {
  switch (action.type) {
    case 'submit':
      return { status: 'submitting', error: null }
    case 'saved':
      return { status: 'success', error: null }
    case 'failed':
      return { status: 'failure', error: action.message }
    case 'retry':
      return { status: 'editing', error: null }
  }
}

I like this because it centralizes the workflow rules in one place and makes unit testing cheap.

2. Use a state machine when invalid transitions are expensive

Reducers are enough for many screens. I move toward a machine mindset when I need to answer:

  • can this event happen in this state?
  • what transitions are impossible?
  • what side effects happen on entry or exit?
  • how do retries and cancellations work?
The point of a machine is not ceremony. The point is to make invalid transitions visible and deliberate.

3. Real-world example: approval workflow

Imagine a review screen where a manager can approve, reject, request changes, or bulk-apply actions to selected items.

The dangerous bugs are usually not “button did nothing.” They are:

  • approve clicked twice during retry
  • modal closed but mutation still resolves into the old screen
  • stale selection used for a bulk action
  • screen says success while the backend only partially succeeded

That is exactly where explicit transition logic pays for itself.

4. Keep side effects outside the pure transition logic

function ApprovalScreen() {
  const [state, dispatch] = useReducer(approvalReducer, initialState)

  async function handleApprove() {
    dispatch({ type: 'approveStarted' })

    try {
      await approveItems(state.selectedIds)
      dispatch({ type: 'approveSucceeded' })
    } catch (error) {
      dispatch({
        type: 'approveFailed',
        message: error instanceof Error ? error.message : 'Unknown error',
      })
    }
  }

  return <ApprovalView state={state} onApprove={handleApprove} />
}

I want the reducer pure and the effect orchestration explicit.

Trade-offs

  • Reducers make workflows clearer, but they add ceremony for simple components.
  • State-machine thinking prevents invalid states, but it requires discipline and naming clarity.
  • Explicit transition models improve testing and debugging, but they can feel heavy if the screen is mostly read-only or linear.
  • Teams sometimes over-formalize simple flows; the right level depends on the cost of a wrong transition.

References