Introduction
In React, managing component state is crucial for building dynamic and interactive applications. While the useState hook is sufficient for simple state management, more complex state logic often requires structured handling of state transitions. This is where the useReducer hook comes in.
useReducer is ideal for managing state that involves multiple sub-values, complex updates, or dependent actions. It provides a predictable pattern inspired by Redux, making it easier to reason about state changes.
In this post, we will cover:
- When to use
useReducerinstead ofuseState. - How to implement the reducer function pattern.
- Using action types and
dispatchfor state updates. - A practical example: a counter with multiple actions.
When to Use useReducer
While useState works for most simple cases, useReducer becomes useful when:
- Complex state logic: State depends on multiple variables or requires conditional updates.
- Multiple actions: Different user interactions trigger varied state changes.
- Predictable state transitions: Reducers allow centralized, explicit handling of state updates.
- Performance optimization: Reduces unnecessary re-renders by grouping state logic.
- Readability and maintainability: Especially in large components.
Example Scenario
- A counter with multiple actions (increment, decrement, reset).
- A form with multiple fields and validation rules.
- A todo list with add, delete, toggle completion actions.
The Reducer Function Pattern
At the core of useReducer is the reducer function, a pure function that takes current state and an action, and returns a new state.
Reducer Syntax
function reducer(state, action) {
switch(action.type) {
case 'ACTION_TYPE':
return { ...state, ...updatedValues };
default:
return state;
}
}
state: Current state object or value.action: Object describing the update (usually contains atypeand optional payload).- Must always return new state, never mutate the existing state.
Basic useReducer Example
import React, { useReducer } from 'react';
const initialState = { count: 0 };
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, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
<button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
export default Counter;
Explanation:
useReducerreturns an array[state, dispatch].stateholds the current state.dispatchtriggers a state update by sending an action to the reducer.- The reducer handles different action types using a
switchstatement.
Action Types and Dispatch
Actions describe what should happen to the state. They are usually objects with a type property. Optionally, they can include a payload for additional data.
Example with Payload
function reducer(state, action) {
switch(action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'addAmount':
return { count: state.count + action.payload };
default:
return state;
}
}
// Dispatch example
dispatch({ type: 'addAmount', payload: 5 });
- The
payloadallows dynamic updates. - This pattern scales well for complex logic and multiple actions.
Advantages of useReducer
- Centralized logic: All state updates are in the reducer.
- Predictable state transitions: Reducer is a pure function.
- Scalable: Handles multiple actions cleanly.
- Easier testing: Reducer functions can be unit tested independently.
- Avoids stale state: Unlike
useState, updates are managed centrally.
Example: Counter with Multiple Actions
Here’s a full example demonstrating multiple state transitions:
import React, { useReducer } from 'react';
const initialState = { count: 0, step: 1 };
function reducer(state, action) {
switch(action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'reset':
return { ...state, count: 0 };
case 'setStep':
return { ...state, step: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<p>Step: {state.step}</p>
<button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
<button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
<input
type="number"
value={state.step}
onChange={(e) => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
/>
</div>
);
}
export default Counter;
Key Features:
countandstepare managed together in a single state object.- Action
setStepallows dynamic step changes. - Increment and decrement use the current
stepvalue. - This pattern scales well for additional actions.
Using useReducer for Complex Forms
useReducer is particularly useful for forms with multiple fields and validation.
const initialFormState = {
username: "",
email: "",
password: ""
};
function formReducer(state, action) {
switch(action.type) {
case 'updateField':
return { ...state, [action.field]: action.value };
case 'resetForm':
return initialFormState;
default:
return state;
}
}
function RegistrationForm() {
const [formState, dispatch] = useReducer(formReducer, initialFormState);
const handleChange = (e) => {
dispatch({ type: 'updateField', field: e.target.name, value: e.target.value });
};
const handleReset = () => {
dispatch({ type: 'resetForm' });
};
return (
<div>
<input name="username" value={formState.username} onChange={handleChange} placeholder="Username" />
<input name="email" value={formState.email} onChange={handleChange} placeholder="Email" />
<input name="password" value={formState.password} onChange={handleChange} placeholder="Password" />
<button onClick={handleReset}>Reset</button>
<p>{JSON.stringify(formState)}</p>
</div>
);
}
- State updates are centralized and predictable.
- Adding more fields requires minimal changes.
- Avoids multiple
useStatecalls for each field.
Combining useReducer with useEffect
useReducer works well with useEffect for side effects like API calls or saving to localStorage.
import React, { useReducer, useEffect } from 'react';
const initialState = { todos: [], input: "" };
function reducer(state, action) {
switch(action.type) {
case 'setInput':
return { ...state, input: action.payload };
case 'addTodo':
return { ...state, todos: [...state.todos, state.input], input: "" };
case 'removeTodo':
return { ...state, todos: state.todos.filter((_, i) => i !== action.payload) };
case 'setTodos':
return { ...state, todos: action.payload };
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const savedTodos = JSON.parse(localStorage.getItem("todos") || "[]");
dispatch({ type: 'setTodos', payload: savedTodos });
}, []);
useEffect(() => {
localStorage.setItem("todos", JSON.stringify(state.todos));
}, [state.todos]);
return (
<div>
<input value={state.input} onChange={e => dispatch({ type: 'setInput', payload: e.target.value })} />
<button onClick={() => dispatch({ type: 'addTodo' })}>Add Todo</button>
<ul>
{state.todos.map((todo, i) => (
<li key={i}>
{todo} <button onClick={() => dispatch({ type: 'removeTodo', payload: i })}>Delete</button>
</li>
))}
</ul>
</div>
);
}
- All state transitions are explicit and centralized.
- Integrates seamlessly with
useEffectfor persistence. - Scales easily for additional functionality.
Best Practices for useReducer
- Keep reducer pure: No side effects inside the reducer.
- Use descriptive action types for readability.
- Combine related state into a single object if updates are interdependent.
- Dispatch payloads for dynamic updates.
- Separate effects from state logic using
useEffect. - Test reducers independently for predictable state management.
Leave a Reply