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.
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}- event
return { on: state.on, toggle: () => dispatch({ type: 'toggle' }) }Click the switch. The hook dispatches its normal action; it knows nothing about custom rules. - render phase
return toggleReducer(state, action)useReducerruns the consumer’s reducer. The order isn’t placed, so it delegates to the exported default. - commit phase
if (action.type === 'toggle') return { on: !state.on }A new state,{ on: true }:GiftOptionsandWrapNoterender.
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}- event
const [orderPlaced, setOrderPlaced] = useState(false)Click Place order, then click the switch again. The same action is dispatched. - render phase
if (orderPlaced && action.type === 'toggle') return stateThe consumer’s reducer returns the state it was given: a veto, with no special API. - commit phaseRecorded:
GiftOptionsstill ran once (React runs the reducer while rendering it), then bailed out:WrapNotedidn’t render, and the switch stayed on.
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
useReducernever cared where the reducer came from. The extension point already exists: it’s the first argument. The pattern is just “don’t hard-code it”.- The latest reducer is used. The consumer’s reducer is an inline function that reads
orderPlacedfrom the current render; React runs the queued action with the reducer from the render that processes it, so it sees the current value. - Returning
stateis a veto. Same object → React bails out (useReducer). Return a new object only when something should change. - Delegate to the exported default.
return toggleReducer(state, action)handles every case you didn’t customize, and keeps working when the hook adds new action types. - The action shapes become public API. Consumers’ reducers depend on
{ type: 'toggle' }and{ type: 'reset', initialState }. Renaming an action is a breaking change. Downshift exports its action types as constants for this reason.
Common mistakes
- Forcing consumers to reimplement the whole reducer because the default isn’t exported.
- Returning
{ ...state }to mean “no change”. That’s a new object, so it’s treated as a change. - Using it when the consumer needs to change the state from outside (a reset button elsewhere, syncing two components). The reducer only sees the hook’s own actions; for that, use Control Props.
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.
Related
- useReducer: reducers and bail-outs.
- State Initializers: the previous step of this hook.
- Control Props: the next step, the consumer owns the value.
Sources
- Kent C. Dodds: The state reducer pattern with React Hooks, When to use control props or state reducers
- Downshift’s
stateReducerprop - Topic order inspired by Kent C. Dodds’ EpicReact Advanced React Patterns workshop; the example app and code here are this handbook’s own.
Primary source: kentcdodds.com/blog/the-state-reducer-pattern-with-react-hooks