Must knowAdvanced React Patterns · 07

The State Reducer Pattern

Let the consumer decide what each action does, by passing their own reducer that can veto or delegate.

On this page · 7 sections
  1. In one minute
  2. The example: locking gift wrap after the order
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

In one minute

A reusable hook can’t predict every rule its users will want: “don’t close on outside click”, “keep the menu open after selecting”, “no changes after the order is placed”. Adding a boolean option for each doesn’t scale. The state reducer pattern is inversion of control: the hook takes a reducer option and uses it instead of its own. The hook still decides when an action happens (a click dispatches { type: 'toggle' }); the consumer decides what it does. Export the default reducer so consumers can override one case and delegate the rest.

You’ll be able to: add a reducer option to a hook, veto an action from outside, and explain what React does when a reducer returns the same state.

Before the order
GiftOptions.tsx
1export function toggleReducer(state, action) {2  if (action.type === 'toggle') return { on: !state.on }3  if (action.type === 'reset') return action.initialState4}5function useToggle({ reducer = toggleReducer } = {}) {6  const [state, dispatch] = useReducer(reducer, { on: false })7  return { on: state.on, toggle: () => dispatch({ type: 'toggle' }) }8}910function GiftOptions() {11  const [orderPlaced, setOrderPlaced] = useState(false)12  const { on, toggle } = useToggle({13    reducer(state, action) {14      if (orderPlaced && action.type === 'toggle') return state15      return toggleReducer(state, action)16    },17  })18  return <><Switch on={on} onClick={toggle} /><WrapNote on={on} /></>19}
What you see
Gift wrap: off
Place order
Rendered this step
GiftOptions–
WrapNote–
  1. eventreturn { on: state.on, toggle: () => dispatch({ type: 'toggle' }) }Click the switch. The hook dispatches its normal action; it knows nothing about custom rules.
  2. render phasereturn toggleReducer(state, action)useReducer runs the consumer’s reducer. The order isn’t placed, so it delegates to the exported default.
  3. commit phaseif (action.type === 'toggle') return { on: !state.on }A new state, { on: true }: GiftOptions and WrapNote render.

0 / 0
Once the order is placed, gift wrap can’t be changed. The page adds that rule from outside, through its own reducer (recorded).

The example: locking gift wrap after the order

Once the order is placed, the gift-wrap choice can’t change. That’s a rule of this checkout page, not of useToggle, so it belongs to the consumer.

1. The hook takes a reducer

type ToggleState = { on: boolean }
type ToggleAction = { type: 'toggle' } | { type: 'reset'; initialState: ToggleState }

export function toggleReducer(state: ToggleState, action: ToggleAction): ToggleState {
	switch (action.type) {
		case 'toggle':
			return { on: !state.on }
		case 'reset':
			return action.initialState
	}
}

function useToggle({ initialOn = false, reducer = toggleReducer } = {}) {
	const { current: initialState } = useRef({ on: initialOn })
	const [state, dispatch] = useReducer(reducer, initialState)
	const toggle = () => dispatch({ type: 'toggle' })
	const reset = () => dispatch({ type: 'reset', initialState })
	return { on: state.on, toggle, reset }
}

The only change from State Initializers is reducer = toggleReducer as an option, and export on the default reducer.

2. The consumer’s rule

function GiftOptions() {
	const [orderPlaced, setOrderPlaced] = useState(false)
	const { on, toggle } = useToggle({
		reducer(state, action) {
			if (orderPlaced && action.type === 'toggle') return state // too late: keep it as is
			return toggleReducer(state, action) // everything else: the default behavior
		},
	})
	log(`render GiftOptions (on: ${on})`)
	return (
		<>
			<Switch id="gift" on={on} onClick={toggle} aria-label="Gift wrap" />
			<WrapNote on={on} />
			<button id="place" onClick={() => setOrderPlaced(true)}>Place order</button>
		</>
	)
}

Recorded: click the switch, place the order, click the switch again.

render GiftOptions (on: true)
render WrapNote
render GiftOptions (on: true)
render WrapNote
render GiftOptions (on: true)

The last click was vetoed: the switch stayed on. Look at what rendered. React did call GiftOptions again, because useReducer runs the reducer while rendering the component. Then it saw the same state object and bailed out: WrapNote didn’t render and nothing changed on the page. (It’s often said that React skips the render entirely. The recording shows the component itself still runs once; only its children are skipped.)

How it works

Common mistakes

Interview Q&A

What problem does the state reducer pattern solve?

Consumers need custom behavior that the hook author can’t predict. Instead of adding an option per request, the hook accepts a reducer and lets the consumer decide how each action changes the state.

Why is it called inversion of control?

Normally the hook decides what every action does. Here the hook still decides when actions happen, but hands the decision of what they do to the caller.

How does a consumer veto an action?

Return the current state unchanged. Recorded: after the order was placed, clicking the switch left it on. GiftOptions rendered once and bailed out; its child WrapNote didn’t render.

Why export the default reducer?

So a custom reducer can override one case and delegate everything else: return toggleReducer(state, action). Without it, every consumer copies the whole reducer and it drifts from the hook’s.

State reducer or control props?

State reducer when the consumer wants to change how the component reacts to its own actions. Control props when the consumer needs to own the value, change it from outside, or keep several components in sync. Downshift supports both.

Sources

Primary source: kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks