🧠 The State Reducer Pattern in React: Giving Control Back to Developers
As a library author or even just a dev building reusable hooks or components, you’ve probably run into this problem:
“Can we make this component behave slightly differently in just this one scenario?”
You could add a new prop. Then a new branch of logic. Then another. Soon enough, your component becomes a bloated maze of flags and conditionals.
There’s a better way.
Enter: the State Reducer Pattern — a clean and powerful way to hand control back to the consumer of your hook or component.
Let’s say you’re building a useToggle
hook. Pretty standard:
function toggleReducer(state, action) {switch (action.type) {case 'toggle':return { on: !state.on }case 'reset':return { on: false }default:throw new Error(`Unhandled action: ${action.type}`)}}function useToggle() {const [state, dispatch] = useReducer(toggleReducer, { on: false })const toggle = () => dispatch({ type: 'toggle' })const reset = () => dispatch({ type: 'reset' })return { on: state.on, toggle, reset }}
Now, someone wants to prevent toggling after 4 clicks. 😅 Should you:
maxToggleCount
prop?clickCount
internally?AdvancedToggle
?Nope. That’s where the State Reducer Pattern shines.
With this pattern, instead of baking every state rule into the hook, you let users decide how state updates.
They pass in a custom reducer, and your hook delegates state changes to it:
function useToggle({ reducer = defaultToggleReducer } = {}) {const [state, dispatch] = useReducer(reducer, { on: false })const toggle = () => dispatch({ type: 'toggle' })const reset = () => dispatch({ type: 'reset' })return { on: state.on, toggle, reset }}
Now, the user can implement their own logic:
function customToggleReducer(state, action) {if (action.type === 'toggle' && state.timesClicked >= 4) {return state // ignore the action}switch (action.type) {case 'toggle':return { on: !state.on, timesClicked: state.timesClicked + 1 }case 'reset':return { on: false, timesClicked: 0 }default:return state}}const { on, toggle, reset } = useToggle({ reducer: customToggleReducer })
✅ You just inverted control of the state logic without having to clutter your original hook.
Your hook might have some useful behavior that developers still want, even if they’re customizing things. Rewriting the entire reducer from scratch just to tweak one behavior? No thanks.
Here’s how you fix that: export your default reducer so users can call it within theirs.
export function defaultToggleReducer(state, action) {switch (action.type) {case 'toggle':return { on: !state.on }case 'reset':return { on: false }default:return state}}
Now consumers can write:
function toggleStateReducer(state, action) {if (action.type === 'toggle' && state.clicks >= 4) {return state}// Let the default handle the restreturn defaultToggleReducer(state, action)}
This lets them opt in to just enough control, without reimplementing your entire logic.
This pattern promotes composability and flexibility without sacrificing simplicity in the base implementation.
Benefits | How |
---|---|
🔄 Inversion of control | Consumer manages state transitions |
🧱 Extensibility | Handle custom actions and constraints |
🚫 Avoid prop explosion | Don’t add a prop for every possible behavior |
♻️ Reuse default logic | Consumers can call your reducer for standard actions |
useReducer
instead of useState
when building advanced hooks—it naturally supports this pattern.downshift
The popular autocomplete library downshift
is a poster child of this pattern. It uses the stateReducer pattern to give users complete control over how the dropdown behaves—without ever having to fork the library.
The State Reducer Pattern gives you the best of both worlds:
As your hook’s usage grows and edge cases pop up, this pattern helps you scale flexibility without growing complexity.
🛠️ Let your users decide what state changes mean—for them.
Need a working CodeSandbox or full example of useToggle
using this pattern? Let me know and I’ll share a ready-to-use version. 🧑🔧
Happy reducing! ✂️
Quick Links
Legal Stuff
Social Media