useState vs. useReducer: A Practical Decision Guide
Decide when to use React's useState or useReducer for local component state. A concise decision matrix, trade-offs, and a concrete reducer pattern guide.
16 Sept 2025, 05:29 UTC

When to use useState
useState is ideal for a handful of independent, primitive values—booleans, numbers, strings, or simple objects that don't need to change together. Because the setter is a simple function, the logic lives inline in event handlers, keeping the component readable. Both hooks have been part of the stable public API since React 16.8, so this guidance applies to any modern React project.
When to switch to useReducer
Switch to useReducer when state transitions become interdependent or when a single user action must update several fields atomically. Reducers centralize transition logic in one pure function—(state, action) => nextState—making the code easier to test and reason about. A common trigger is a component accumulating chained setState calls that must stay consistent.
Compact Trade-off Table
| Feature | useState | useReducer |
|---|---|---|
| Boilerplate | Minimal – one line per value | Action types + reducer function |
| Logic Placement | Inline in handlers | Centralized pure function |
| Atomicity | Separate updates may interleave | Single dispatch updates all fields |
| Testability | Requires component render | Reducer is a pure function → unit testable |
| Performance | Same as useReducer – no measurable difference | Same as useState – no measurable difference |
Performance is essentially a wash: both hooks schedule a re-render per update, and both the setter and dispatch are referentially stable across renders. Choose based on state shape and maintainability, and measure with the React DevTools Profiler before claiming any gain.
Concrete Reducer Pattern
Below is a minimal, type-safe reducer for a form that tracks field values, validity, and an error message. The reducer is pure, returns a new state object, and ignores unknown actions. This code targets React 16.8 or later; run it in a client component (hooks cannot run in React Server Components).
import React, { useReducer } from 'react';
const initialState = {
username: '',
email: '',
isValid: false,
error: null,
};
type Action =
| { type: 'SET_FIELD'; field: 'username' | 'email'; value: string }
| { type: 'VALIDATE'; isValid: boolean; error: string | null }
| { type: 'RESET' };
function reducer(state = initialState, action: Action) {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value, error: null };
case 'VALIDATE':
return { ...state, isValid: action.isValid, error: action.error };
case 'RESET':
return initialState;
default:
return state;
}
}
export function Form() {
const [state, dispatch] = useReducer(reducer, initialState);
const handleChange = (e) => {
dispatch({ type: 'SET_FIELD', field: e.target.name, value: e.target.value });
};
const handleSubmit = (e) => {
e.preventDefault();
const isValid = state.email.includes('@');
dispatch({ type: 'VALIDATE', isValid, error: isValid ? null : 'Invalid email' });
};
return (
<form onSubmit={handleSubmit}>
<input name="username" value={state.username} onChange={handleChange} />
<input name="email" value={state.email} onChange={handleChange} />
<button type="submit">Submit</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
Note the lazy-initialization signatures differ between the hooks: useState takes an initializer function directly, while useReducer takes (reducer, initialArg, init). Mixing these up is a common bug.
Validation & Testing
- Unit-test the reducer as a plain function: provide (state, action) pairs and assert the returned state. No DOM or renderer is needed, and mutation bugs fail fast.
- Run the component under
React.StrictModein development; StrictMode intentionally double-invokes reducers, so an impure reducer will produce visible bugs. - Integration-test the component by dispatching through real user events (typing, submitting).
- If using TypeScript, compile the sample: the dispatch argument type is inferred from the reducer's action parameter, which validates the action union.
- Use the React DevTools Profiler to confirm that neither hook introduces extra renders beyond the state change.
Boundary Conditions
If a value can be computed from existing state or props during render, neither hook is appropriate—derive it directly (memoizing with useMemo only if measurably expensive) rather than mirroring it into state, and do not use effects to synchronize state with state.
Migration Checklist
- Identify state slices that change together or depend on each other.
- Replace the corresponding
useStatecalls with a singleuseReducerhook. - Move all update logic into a pure reducer; keep event handlers thin, dispatching actions.
- Add unit tests for the new reducer before merging.
- Verify that the component still renders correctly and that the UI behaves as before.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.