The state reducer pattern predates hooks. It was originally built for render-prop components, most notably Downshift, an "enhanced input" component for building accessible autocomplete, typeahead and dropdown UIs. Downshift manages four pieces of state: isOpen, selectedItem, highlightedIndex and inputValue, and it exposed them through a render prop because that was the best available way to ship a headless UI component — logic shared, UI unopinionated.

The pattern exists to give inversion of control: the API author lets the API consumer decide how internal updates actually happen. The canonical example is a multi-select built on Downshift. Downshift's author decided that picking an item should set isOpen to false and close the menu; the multi-select user wanted the menu to stay open so more items could be selected. Inverting control over state updates covered that case and any other case anyone might want.

With hooks available, the same idea transfers cleanly — and arguably better than it did for class or render-prop components. Downshift itself has plans to implement a hook.

The mechanics

  1. The end user performs an action.
  2. The developer calls dispatch.
  3. The hook works out the changes that are needed.
  4. The hook calls the developer's code so it can adjust those changes — this is the inversion of control step.
  5. The hook commits the state change.

Starting point

The walkthrough below uses a deliberately small useToggle hook and a matching <Toggle /> component. The example is contrived on purpose: the pattern pays off on complex hooks and components, and a small one keeps the mechanics visible.

function useToggle() {
	const [on, setOnState] = React.useState(false)

	const toggle = () => setOnState((o) => !o)
	const setOn = () => setOnState(true)
	const setOff = () => setOnState(false)

	return { on, toggle, setOn, setOff }
}

function Toggle() {
	const { on, toggle, setOn, setOff } = useToggle()

	return (
		<div>
			<button onClick={setOff}>Switch Off</button>
			<button onClick={setOn}>Switch On</button>
			<Switch on={on} onClick={toggle} />
		</div>
	)
}

function App() {
	return <Toggle />
}

ReactDOM.render(<App />, document.getElementById('root'))

A requirement makes the limitation concrete. Suppose the <Toggle /> component must stop the user clicking the <Switch /> more than four times in a row, unless they press "Reset".

function Toggle() {
	const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
	const tooManyClicks = clicksSinceReset >= 4

	const { on, toggle, setOn, setOff } = useToggle()

	function handleClick() {
		toggle()
		setClicksSinceReset((count) => count + 1)
	}

	return (
		<div>
			<button onClick={setOff}>Switch Off</button>
			<button onClick={setOn}>Switch On</button>
			<Switch on={on} onClick={handleClick} />
			{tooManyClicks ? (
				<button onClick={() => setClicksSinceReset(0)}>Reset</button>
			) : null}
		</div>
	)
}

An if statement inside handleClick, skipping the toggle call when tooManyClicks is true, would solve it. But the goal here is to invert control inside the hook instead.

The desired API lets the consumer intercept every state update before it lands and modify it:

function Toggle() {
	const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
	const tooManyClicks = clicksSinceReset >= 4

	const { on, toggle, setOn, setOff } = useToggle({
		modifyStateChange(currentState, changes) {
			if (tooManyClicks) {
				// other changes are fine, but on needs to be unchanged
				return { ...changes, on: currentState.on }
			} else {
				// the changes are fine
				return changes
			}
		},
	})

	function handleClick() {
		toggle()
		setClicksSinceReset((count) => count + 1)
	}

	return (
		<div>
			<button onClick={setOff}>Switch Off</button>
			<button onClick={setOn}>Switch On</button>
			<Switch on={on} onClick={handleClick} />
			{tooManyClicks ? (
				<button onClick={() => setClicksSinceReset(0)}>Reset</button>
			) : null}
		</div>
	)
}

As written that blocks the "Switch Off" and "Switch On" buttons too, and only the <Switch /> should be prevented from toggling state. So rename modifyStateChange to reducer and give it an action second argument. The action carries a type describing which change is happening, and the changes themselves come from a toggleReducer exported by useToggle. The type for clicking the switch is TOGGLE.

function Toggle() {
	const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
	const tooManyClicks = clicksSinceReset >= 4

	const { on, toggle, setOn, setOff } = useToggle({
		reducer(currentState, action) {
			const changes = toggleReducer(currentState, action)
			if (tooManyClicks && action.type === 'TOGGLE') {
				// other changes are fine, but on needs to be unchanged
				return { ...changes, on: currentState.on }
			} else {
				// the changes are fine
				return changes
			}
		},
	})

	function handleClick() {
		toggle()
		setClicksSinceReset((count) => count + 1)
	}

	return (
		<div>
			<button onClick={setOff}>Switch Off</button>
			<button onClick={setOn}>Switch On</button>
			<Switch on={on} onClick={handleClick} />
			{tooManyClicks ? (
				<button onClick={() => setClicksSinceReset(0)}>Reset</button>
			) : null}
		</div>
	)
}

Rather than passing the 'TOGGLE' string around, expose an object of change types for consumers to reference. That removes typo risk and improves editor autocompletion for anyone not using TypeScript.

function Toggle() {
	const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
	const tooManyClicks = clicksSinceReset >= 4

	const { on, toggle, setOn, setOff } = useToggle({
		reducer(currentState, action) {
			const changes = toggleReducer(currenState, action)
			if (tooManyClicks && action.type === actionTypes.toggle) {
				// other changes are fine, but on needs to be unchanged
				return { ...changes, on: currentState.on }
			} else {
				// the changes are fine
				return changes
			}
		},
	})

	function handleClick() {
		toggle()
		setClicksSinceReset((count) => count + 1)
	}

	return (
		<div>
			<button onClick={setOff}>Switch Off</button>
			<button onClick={setOn}>Switch On</button>
			<Switch on={on} onClick={handleClick} />
			{tooManyClicks ? (
				<button onClick={() => setClicksSinceReset(0)}>Reset</button>
			) : null}
		</div>
	)
}

Rewriting the hook around useReducer

Adding the interception logic to each helper function in the original useToggle would be tedious, even at this size. Converting the hook from useState to useReducer makes the implementation far simpler. The starting code:

function useToggle() {
	const [on, setOnState] = React.useState(false)

	const toggle = () => setOnState((o) => !o)
	const setOn = () => setOnState(true)
	const setOff = () => setOnState(false)

	return { on, toggle, setOn, setOff }
}

The reducer version:

function toggleReducer(state, action) {
	switch (action.type) {
		case 'TOGGLE': {
			return { on: !state.on }
		}
		case 'ON': {
			return { on: true }
		}
		case 'OFF': {
			return { on: false }
		}
		default: {
			throw new Error(`Unhandled type: ${action.type}`)
		}
	}
}

function useToggle() {
	const [{ on }, dispatch] = React.useReducer(toggleReducer, { on: false })

	const toggle = () => dispatch({ type: 'TOGGLE' })
	const setOn = () => dispatch({ type: 'ON' })
	const setOff = () => dispatch({ type: 'OFF' })

	return { on, toggle, setOn, setOff }
}

Add the types property to useToggle to avoid bare strings, and export it so hook consumers can reference it:

const actionTypes = {
	toggle: 'TOGGLE',
	on: 'ON',
	off: 'OFF',
}
function toggleReducer(state, action) {
	switch (action.type) {
		case actionTypes.toggle: {
			return { on: !state.on }
		}
		case actionTypes.on: {
			return { on: true }
		}
		case actionTypes.off: {
			return { on: false }
		}
		default: {
			throw new Error(`Unhandled type: ${action.type}`)
		}
	}
}

function useToggle() {
	const [{ on }, dispatch] = React.useReducer(toggleReducer, { on: false })

	const toggle = () => dispatch({ type: actionTypes.toggle })
	const setOn = () => dispatch({ type: actionTypes.on })
	const setOff = () => dispatch({ type: actionTypes.off })

	return { on, toggle, setOn, setOff }
}

export { useToggle, actionTypes }

Consumers pass their reducer through the configuration object, so useToggle needs to accept it:

function useToggle({ reducer }) {
	const [{ on }, dispatch] = React.useReducer(toggleReducer, { on: false })

	const toggle = () => dispatch({ type: actionTypes.toggle })
	const setOn = () => dispatch({ type: actionTypes.on })
	const setOff = () => dispatch({ type: actionTypes.off })

	return { on, toggle, setOn, setOff }
}

The question then is how the developer's reducer combines with the built-in one. True inversion of control means not quietly calling the hook's own reducer behind the developer's back. Instead, export that reducer so consumers can use it themselves, and call the reducer they supplied:

function useToggle({ reducer }) {
	const [{ on }, dispatch] = React.useReducer(reducer, { on: false })

	const toggle = () => dispatch({ type: actionTypes.toggle })
	const setOn = () => dispatch({ type: actionTypes.on })
	const setOff = () => dispatch({ type: actionTypes.off })

	return { on, toggle, setOn, setOff }
}

export { useToggle, actionTypes, toggleReducer }

That forces every consumer to provide a reducer, which is not the intent — only the people who want control should have to do anything. Defaults fix it:

function useToggle({ reducer = toggleReducer } = {}) {
	const [{ on }, dispatch] = React.useReducer(reducer, { on: false })

	const toggle = () => dispatch({ type: actionTypes.toggle })
	const setOn = () => dispatch({ type: actionTypes.on })
	const setOff = () => dispatch({ type: actionTypes.off })

	return { on, toggle, setOn, setOff }
}

export { useToggle, actionTypes, toggleReducer }

At this point useToggle works with a caller-supplied reducer or with the built-in one, equivalently.

Final version and trade-off

import * as React from 'react'
import ReactDOM from 'react-dom'
import Switch from './switch'

const actionTypes = {
	toggle: 'TOGGLE',
	on: 'ON',
	off: 'OFF',
}

function toggleReducer(state, action) {
	switch (action.type) {
		case actionTypes.toggle: {
			return { on: !state.on }
		}
		case actionTypes.on: {
			return { on: true }
		}
		case actionTypes.off: {
			return { on: false }
		}
		default: {
			throw new Error(`Unhandled type: ${action.type}`)
		}
	}
}

function useToggle({ reducer = toggleReducer } = {}) {
	const [{ on }, dispatch] = React.useReducer(reducer, { on: false })

	const toggle = () => dispatch({ type: actionTypes.toggle })
	const setOn = () => dispatch({ type: actionTypes.on })
	const setOff = () => dispatch({ type: actionTypes.off })

	return { on, toggle, setOn, setOff }
}

// export {useToggle, actionTypes, toggleReducer}

function Toggle() {
	const [clicksSinceReset, setClicksSinceReset] = React.useState(0)
	const tooManyClicks = clicksSinceReset >= 4

	const { on, toggle, setOn, setOff } = useToggle({
		reducer(currentState, action) {
			const changes = toggleReducer(currentState, action)
			if (tooManyClicks && action.type === actionTypes.toggle) {
				// other changes are fine, but on needs to be unchanged
				return { ...changes, on: currentState.on }
			} else {
				// the changes are fine
				return changes
			}
		},
	})

	return (
		<div>
			<button onClick={setOff}>Switch Off</button>
			<button onClick={setOn}>Switch On</button>
			<Switch
				onClick={() => {
					toggle()
					setClicksSinceReset((count) => count + 1)
				}}
				on={on}
			/>
			{tooManyClicks ? (
				<button onClick={() => setClicksSinceReset(0)}>Reset</button>
			) : null}
		</div>
	)
}

function App() {
	return <Toggle />
}

ReactDOM.render(<App />, document.getElementById('root'))

A runnable version lives in this codesandbox.

What the hooks version delivers is the ability for consumers to hook into every state update of the reducer and modify it, which makes the hook substantially more flexible. The cost is that the way state is updated becomes part of the public API: changing that behaviour later can be a breaking change. For complex hooks and components the trade-off is worth it — just make the decision knowingly.

useReducer makes this pattern fall out almost for free, which is what makes it worth trying in existing codebases.