Two hooks can do the same job, but that doesn't make one a replacement for the other. Sometimes the older API exists purely for backward compatibility; other times both APIs are current and simply carry different trade-offs. useState and useReducer fall into the second bucket, so the useful question is not which one wins, but which trade-offs apply to the state you're managing.
Two examples make the distinction concrete: a custom useDarkMode hook that suits useState, and a custom useUndo hook that suits useReducer.
An independent value: useDarkMode
The hook reads the user's stored preference, initializes mode to dark or light accordingly, returns mode and setMode, and writes the current value back to localStorage whenever the mode changes — whether through a direct setMode call or because the user changed a system preference.
function useDarkMode() {
const preferDarkQuery = '(prefers-color-scheme: dark)'
const [mode, setMode] = React.useState(
() =>
window.localStorage.getItem('colorMode') ||
(window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
)
React.useEffect(() => {
const mediaQuery = window.matchMedia(preferDarkQuery)
const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
mediaQuery.addListener(handleChange)
return () => mediaQuery.removeListener(handleChange)
}, [])
React.useEffect(() => {
window.localStorage.setItem('colorMode', mode)
}, [mode])
return [mode, setMode]
}
Written with a reducer, the same hook gets worse. The typical Redux-style reducer is far more code than the useState version:
const preferDarkQuery = '(prefers-color-scheme: dark)'
function darkModeReducer(state, action) {
switch (action.type) {
case 'MEDIA_CHANGE': {
return { ...state, mode: action.mode }
}
case 'SET_MODE': {
// make sure to spread that state just in case!
return { ...state, mode: action.mode }
}
default: {
// helps us avoid typos!
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
// use the init function to lazily initialize state so we don't read into
// localstorage or call matchMedia every render
function init() {
return {
mode:
window.localStorage.getItem('colorMode') ||
(window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
}
}
function useDarkMode() {
const [state, dispatch] = React.useReducer(
darkModeReducer,
{ mode: 'light' },
init,
)
const { mode } = state
React.useEffect(() => {
const mediaQuery = window.matchMedia(preferDarkQuery)
const handleChange = () =>
dispatch({
type: 'MEDIA_CHANGE',
mode: mediaQuery.matches ? 'dark' : 'light',
})
mediaQuery.addListener(handleChange)
return () => mediaQuery.removeListener(handleChange)
}, [])
React.useEffect(() => {
window.localStorage.setItem('colorMode', mode)
}, [mode])
// We like the API the way it is, so instead of returning the state object
// and the dispatch function, we'll return the `mode` property and we'll
// create a setMode helper (which we have to memoize in case someone wants
// to use it in a dependency list):
const setMode = React.useCallback(
(newMode) => dispatch({ type: 'SET_MODE', mode: newMode }),
[],
)
return [mode, setMode]
}
A less conventional reducer — one that skips the usual action-dispatch shape — is noticeably shorter:
function useDarkMode() {
const preferDarkQuery = '(prefers-color-scheme: dark)'
const [mode, setMode] = React.useReducer(
(prevMode, nextMode) =>
typeof nextMode === 'function' ? nextMode(prevMode) : nextMode,
'light',
() =>
window.localStorage.getItem('colorMode') ||
(window.matchMedia(preferDarkQuery).matches ? 'dark' : 'light'),
)
React.useEffect(() => {
const mediaQuery = window.matchMedia(preferDarkQuery)
const handleChange = () => setMode(mediaQuery.matches ? 'dark' : 'light')
mediaQuery.addListener(handleChange)
return () => mediaQuery.removeListener(handleChange)
}, [])
React.useEffect(() => {
window.localStorage.setItem('colorMode', mode)
}, [mode])
return [mode, setMode]
}
Even so, that version is effectively useState implemented with useReducer, and it remains less clear than the original. For a single independent element of state, useState is the better tool.
State that changes together: useUndo
The second example takes inspiration from the useUndo hook by Homer Chen on GitHub. Almost everything in it touches state, so there is no small set of highlights to point at.
Why the useState version misbehaves
A first useState implementation of useUndo looks reasonable:
function useUndo(initialPresent) {
const [past, setPast] = React.useState([])
const [present, setPresent] = React.useState(initialPresent)
const [future, setFuture] = React.useState([])
const canUndo = past.length !== 0
const canRedo = future.length !== 0
const undo = React.useCallback(() => {
if (!canUndo) return
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
setPast(newPast)
setPresent(previous)
setFuture([present, ...future])
}, [canUndo, future, past, present])
const redo = React.useCallback(() => {
if (!canRedo) return
const next = future[0]
const newFuture = future.slice(1)
setPast([...past, present])
setPresent(next)
setFuture(newFuture)
}, [canRedo, future, past, present])
const set = React.useCallback(
(newPresent) => {
if (newPresent === present) {
return
}
setPast([...past, present])
setPresent(newPresent)
setFuture([])
},
[past, present],
)
const reset = React.useCallback((newPresent) => {
setPast([])
setPresent(newPresent)
setFuture([])
}, [])
return [
{ past, present, future },
{ set, reset, undo, redo, canUndo, canRedo },
]
}
The instinct that each state updater call causes its own re-render is a common misconception. React batches updates, so calling reset from an event handler or a useEffect callback produces a single re-render. Calling it from an async function such as an HTTP request callback would produce three, though concurrent mode is expected to batch those too. Re-render counts are not the real problem here.
The real problem is stale closures — three of them, one each in undo, redo, and set (but not in reset). A contrived example exposes it:
function Example() {
const [state, { set }] = useUndo('first')
React.useEffect(() => {
set('second')
}, [])
React.useEffect(() => {
set('third')
}, [])
return <pre>{JSON.stringify(state, null, 2)}</pre>
}
The result printed is:
{
"past": ["first"],
"present": "third",
"future": []
}
When it should be:
{
"past": ["first", "second"],
"present": "third",
"future": []
}
The value "second" goes missing because set is absent from the effect dependency array. Adding it is the obvious fix:
function Example() {
const [state, { set }] = useUndo('first')
React.useEffect(() => {
set('second')
}, [set])
React.useEffect(() => {
set('third')
}, [set])
return <pre>{JSON.stringify(state, null, 2)}</pre>
}
And that introduces an infinite loop:
{
"past": [
"first",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"second",
"third",
"... this goes on forever..."
],
"present": "third",
"future": []
}
set is memoized, but its dependencies include past and present, which change when set is called — so the function is recreated, and the effect runs again. This looks contrived, but the same failure can appear when state is updated from network events that return out of the order they were sent.
The useState implementation can be repaired side-stepping the problem:
function useUndo(initialPresent) {
const [state, setState] = React.useState({
past: [],
present: initialPresent,
future: [],
})
const canUndo = state.past.length !== 0
const canRedo = state.future.length !== 0
const undo = React.useCallback(() => {
setState((currentState) => {
const { past, present, future } = currentState
if (past.length === 0) return currentState
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
return {
past: newPast,
present: previous,
future: [present, ...future],
}
})
}, [])
const redo = React.useCallback(() => {
setState((currentState) => {
const { past, present, future } = currentState
if (future.length === 0) return currentState
const next = future[0]
const newFuture = future.slice(1)
return {
past: [...past, present],
present: next,
future: newFuture,
}
})
}, [])
const set = React.useCallback((newPresent) => {
setState((currentState) => {
const { present, past } = currentState
if (newPresent === present) return currentState
return {
past: [...past, present],
present: newPresent,
future: [],
}
})
}, [])
const reset = React.useCallback((newPresent) => {
setState(() => ({
past: [],
present: newPresent,
future: [],
}))
}, [])
return [state, { set, reset, undo, redo, canUndo, canRedo }]
}
Three changes do the work:
- State updater callbacks are used when calling the updater functions, so
currentStatearrives as an argument and the state no longer needs to appear in the dependency array. - All state is combined into a single object, because some values determine others —
redoneedspresentto updatepast, andfutureto updatepresent. canUndoandcanRedoare calculated inside the updater callbacks from the receivedcurrentState, so neither belongs in the dependency array.
The reducer version
const UNDO = 'UNDO'
const REDO = 'REDO'
const SET = 'SET'
const RESET = 'RESET'
function undoReducer(state, action) {
const { past, present, future } = state
const { type, newPresent } = action
switch (type) {
case UNDO: {
if (past.length === 0) return state
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
return {
past: newPast,
present: previous,
future: [present, ...future],
}
}
case REDO: {
if (future.length === 0) return state
const next = future[0]
const newFuture = future.slice(1)
return {
past: [...past, present],
present: next,
future: newFuture,
}
}
case SET: {
if (newPresent === present) return state
return {
past: [...past, present],
present: newPresent,
future: [],
}
}
case RESET: {
return {
past: [],
present: newPresent,
future: [],
}
}
}
}
function useUndo(initialPresent) {
const [state, dispatch] = React.useReducer(undoReducer, {
past: [],
present: initialPresent,
future: [],
})
const canUndo = state.past.length !== 0
const canRedo = state.future.length !== 0
const undo = React.useCallback(() => dispatch({ type: UNDO }), [])
const redo = React.useCallback(() => dispatch({ type: REDO }), [])
const set = React.useCallback(
(newPresent) => dispatch({ type: SET, newPresent }),
[],
)
const reset = React.useCallback(
(newPresent) => dispatch({ type: RESET, newPresent }),
[],
)
return [state, { set, reset, undo, redo, canUndo, canRedo }]
}
With a reducer, the useUndo logic itself becomes simple. Starting from useReducer, nobody would have considered extending the dependency array in the first place, because the functions are trivial and hold no dependencies — the logic lives in the reducer. The switch cases in the reducer are essentially the bodies of the functions from the useState version before the fix.
Rules of thumb
- Managing a single, independent element of state:
useState - A state element that must read another state element's value to update:
useReducer
Beyond those two guidelines, the choice is subjective — as the examples show, everything can be done with either hook. The decision also applies case by case: useState and useReducer can coexist in one component or hook, and a single hook or component can hold multiple of each. Separate state by logical domain. State that changes together usually belongs together in a reducer; state that is independent of the rest gains nothing from being folded in and just adds noise. There is no threshold of useState calls at which you should switch — the signal is whether elements of state need to change together. A reasonable default is to start with useState and move to useReducer when that coupling appears.



