React - useReducer (Basics)
Goal
Use useReducer
to manage component state with a reducer function and actions. This is helpful when state logic becomes complex or when updates depend on the previous state in multiple ways.
When to use it
- State transitions are more complex than simple setters (multiple related fields, different actions).
- You want a single place (the reducer) to define how state changes in response to actions.
- You want to make updates predictable and easier to test.
Minimal pattern
const { useReducer } = React;
function reducer(state, action){
switch(action.type){
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: return state;
}
}
function Counter(){
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: <strong>{state.count}</strong></p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement' })} style={{marginLeft:8}}>-1</button>
<button onClick={() => dispatch({ type: 'reset' })} style={{marginLeft:8}}>Reset</button>
</div>
);
}
Try it
View source
const { useReducer } = React;
function counterReducer(state, action){
switch(action.type){
case 'inc': return { count: state.count + 1 };
case 'dec': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: return state;
}
}
function todoReducer(state, action){
switch(action.type){
case 'add':
if (!action.text?.trim()) return state;
return [...state, { id: crypto.randomUUID?.() || String(Math.random()), text: action.text.trim(), done: false }];
case 'toggle':
return state.map(t => t.id === action.id ? { ...t, done: !t.done } : t);
case 'remove':
return state.filter(t => t.id !== action.id);
default:
return state;
}
}
function Counter(){
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: <strong>{state.count}</strong></p>
<button onClick={() => dispatch({ type: 'inc' })}>+1</button>
<button onClick={() => dispatch({ type: 'dec' })} style={{marginLeft:8}}>-1</button>
<button onClick={() => dispatch({ type: 'reset' })} style={{marginLeft:8}}>Reset</button>
</div>
);
}
function Todos(){
const [text, setText] = React.useState("");
const [todos, dispatch] = useReducer(todoReducer, [
{ id: '1', text: 'Learn useReducer', done: false },
]);
function add(){ dispatch({ type: 'add', text }); setText(''); }
return (
<div>
<div>
<input value={text} onChange={e => setText(e.target.value)} placeholder="New todo" />
<button onClick={add} style={{marginLeft:8}}>Add</button>
</div>
<ul>
{todos.map(t => (
<li key={t.id}>
<label>
<input type="checkbox" checked={t.done} onChange={() => dispatch({ type: 'toggle', id: t.id })} />
<span style={{marginLeft:6}}>{t.text}</span>
</label>
<button onClick={() => dispatch({ type: 'remove', id: t.id })} style={{marginLeft:8}}>✕</button>
</li>
))}
</ul>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('try-usereducer'));
root.render(<>
<Counter />
<hr />
<Todos />
</>);
Syntax primer (for newcomers)
const [state, dispatch] = useReducer(reducer, initialState)
: LikeuseState
but state updates are handled by your reducer function.dispatch({ type, ...payload })
: Send an action describing what happened.reducer(state, action)
: Pure function returning the next state.
Common pitfalls
- Reducers must be pure: no side effects (no fetch, no timers) inside the reducer; use effects outside.
- Do not mutate state in the reducer; always return new objects/arrays.
- Avoid overly generic action types; use specific actions to keep logic clear.
Exercises
- Extend the counter with a configurable step via actions (e.g.,
{'{'}type: 'setStep', step: 5{'}'}
). - Add edit support to the todo list (rename an item with a
'rename'
action). - Write tests for your reducer functions (given state and action, assert next state).