- Published on
When to Use useReducer Instead of useState
- Authors

- Name
- Alex Peng
- @aJinTonic
useReducer is one of those hooks that developers know exists but often feel like they don't need. useState works, why reach for something more complex? The answer is: when your state and the logic that updates it starts to get tangled, useReducer untangles it.
The Problem with Multiple useState Calls
Consider a form with validation:
function SignupForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setErrors({})
const newErrors: Record<string, string> = {}
if (!email) newErrors.email = 'Required'
if (password.length < 8) newErrors.password = 'Min 8 characters'
if (password !== confirmPassword) newErrors.confirmPassword = 'Passwords must match'
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
setIsSubmitting(false)
return
}
try {
await signup({ email, password })
setSubmitted(true)
} catch (err) {
setErrors({ form: 'Something went wrong' })
} finally {
setIsSubmitting(false)
}
}
// ...
}
Six useState calls, and the handleSubmit has to carefully coordinate them. If you forget setIsSubmitting(false) in one of the branches, you have a bug. The state is spread across many variables with no clear relationship between them.
Modelling State as a Single Object
The form has distinct states: idle, submitting, error, success. These are better expressed as a reducer:
type FormState =
| { status: 'idle'; values: FormValues; errors: Record<string, string> }
| { status: 'submitting'; values: FormValues }
| { status: 'error'; values: FormValues; errors: Record<string, string> }
| { status: 'success' }
type FormAction =
| { type: 'UPDATE_FIELD'; field: keyof FormValues; value: string }
| { type: 'SUBMIT' }
| { type: 'SUBMIT_ERROR'; errors: Record<string, string> }
| { type: 'SUBMIT_SUCCESS' }
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'UPDATE_FIELD':
if (state.status === 'submitting') return state
return { ...state, values: { ...state.values, [action.field]: action.value } }
case 'SUBMIT':
return { status: 'submitting', values: state.values as FormValues }
case 'SUBMIT_ERROR':
return { status: 'error', values: state.values as FormValues, errors: action.errors }
case 'SUBMIT_SUCCESS':
return { status: 'success' }
}
}
Now:
function SignupForm() {
const [state, dispatch] = useReducer(formReducer, {
status: 'idle',
values: { email: '', password: '', confirmPassword: '' },
errors: {},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
dispatch({ type: 'SUBMIT' })
const errors = validate(state.values)
if (Object.keys(errors).length > 0) {
dispatch({ type: 'SUBMIT_ERROR', errors })
return
}
try {
await signup(state.values)
dispatch({ type: 'SUBMIT_SUCCESS' })
} catch {
dispatch({ type: 'SUBMIT_ERROR', errors: { form: 'Something went wrong' } })
}
}
// ...
}
The isSubmitting and submitted flags are gone, they're encoded in state.status. The impossible state where isSubmitting and submitted are both true can no longer happen.
The Decision Rule
Use useState when:
- You have one or two independent pieces of state
- Updates to one piece don't depend on another
- The update logic is simple (toggle, increment, set to value)
Use useReducer when:
- Multiple state values are updated together
- The next state depends on the current state in complex ways
- You have distinct state transitions (idle → loading → success/error)
- You want to write unit tests for the state logic in isolation
Testing the Reducer
One underrated benefit: the reducer is a pure function. You can test it without rendering a component:
describe('formReducer', () => {
it('ignores field updates while submitting', () => {
const submittingState = { status: 'submitting', values: { email: 'a@b.com', ... } }
const nextState = formReducer(submittingState, {
type: 'UPDATE_FIELD',
field: 'email',
value: 'hacker@bad.com',
})
expect(nextState).toEqual(submittingState) // unchanged
})
})
No mocking, no rendering. Pure input-output testing.
It's Not Redux
useReducer doesn't require action type constants, action creators, middleware, or a global store. It's just a pattern for encapsulating state transition logic, use it at any scale where it makes the code clearer.