1. Introduction đ
useReducer is React's Hook for managing complex state logic â especially when state transitions depend on multiple related values or well-defined actions. This tutorial covers everything from the basics of reducers to advanced patterns like combining useReducer with Context for scalable global state.
Information
2. What is useReducer? đ¤
useReducer is a Hook that manages state using a reducer function â a pure function that takes the current state and an action, and returns the next state.
Code Snippet
import { useReducer } from 'react';
function reducer(state, action) {
switch (action.type) {
case "increment": return { count: state.count + 1 };
case "decrement": return { count: state.count - 1 };
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
</div>
);
}3. Why Use useReducer? đĄ
- Predictable Transitions: All state changes flow through one central function.
- Complex State Logic: Handles interrelated state values more cleanly than multiple useState calls.
- Easier Testing: Reducers are pure functions, making them simple to unit test in isolation.
- Clear Action History: Actions describe what happened, improving debuggability.
4. useReducer vs useState âī¸
| Aspect | useState | useReducer |
|---|---|---|
| Best For | Simple, independent values | Complex, interrelated state transitions |
| Update Mechanism | Direct setter calls | Dispatching descriptive actions |
| Logic Location | Scattered across event handlers | Centralized in one reducer function |
| Testability | Requires rendering the component | Reducer can be tested as a pure function |
Best Practice
5. Understanding Reducers đ§
A reducer is a pure function with the signature (state, action) => newState. Given the same inputs, it always produces the same output, with no side effects.
Code Snippet
function reducer(state, action) {
// must be pure: no API calls, no mutations, no randomness
switch (action.type) {
case "reset":
return initialState;
default:
return state;
}
}Important
6. Reducer Function âī¸
Code Snippet
function todosReducer(state, action) {
switch (action.type) {
case "add":
return [...state, { id: action.id, text: action.text, done: false }];
case "toggle":
return state.map((todo) =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case "remove":
return state.filter((todo) => todo.id !== action.id);
default:
throw new Error(`Unknown action type: ${action.type}`);
}
}Tip
7. State đĻ
The state managed by useReducer can be any value â a primitive, object, or array â but is most commonly an object or array representing more complex, structured data.
Code Snippet
const initialState = {
todos: [],
filter: "all",
isLoading: false,
};
const [state, dispatch] = useReducer(reducer, initialState);8. Actions đŦ
An action is a plain object describing what happened. By convention, it includes a type field, and optionally other data needed to compute the next state.
Code Snippet
dispatch({ type: "add", text: "Learn useReducer" });
dispatch({ type: "toggle", id: "todo-1" });
dispatch({ type: "setFilter", filter: "completed" });Note
9. Dispatch Function đ¨
dispatch is the function returned alongside state that sends an action to the reducer, triggering a state update and a re-render.
Code Snippet
function TodoForm({ dispatch }) {
const [text, setText] = useState("");
function handleSubmit(e) {
e.preventDefault();
dispatch({ type: "add", text });
setText("");
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}Tip
10. Initial State đą
Code Snippet
const initialState = { count: 0 };
const [state, dispatch] = useReducer(reducer, initialState);Note
11. Lazy Initialization đ¤
useReducer accepts an optional third argument â an initializer function â for computing the initial state lazily, useful when the setup is expensive.
Code Snippet
function init(initialCount) {
return { count: initialCount, history: [] };
}
function reducer(state, action) {
// ...
}
const [state, dispatch] = useReducer(reducer, initialCountProp, init);Tip
12. Action Types đˇī¸
Defining action types as string constants reduces the risk of typos and improves autocompletion, especially as the number of actions grows.
Code Snippet
const ACTIONS = {
ADD_TODO: "add_todo",
TOGGLE_TODO: "toggle_todo",
REMOVE_TODO: "remove_todo",
};
function reducer(state, action) {
switch (action.type) {
case ACTIONS.ADD_TODO:
return [...state, action.payload];
// ...
}
}13. Action Payloads đĻ
A common convention wraps extra action data inside a payload field, keeping the action object's shape consistent across different action types.
Code Snippet
dispatch({
type: "add_todo",
payload: { id: crypto.randomUUID(), text: "Learn reducers" },
});
function reducer(state, action) {
switch (action.type) {
case "add_todo":
return [...state, action.payload];
default:
return state;
}
}14. Multiple Actions đĸ
Code Snippet
function cartReducer(state, action) {
switch (action.type) {
case "add_item":
return { ...state, items: [...state.items, action.payload] };
case "remove_item":
return { ...state, items: state.items.filter((i) => i.id !== action.payload.id) };
case "clear_cart":
return { ...state, items: [] };
case "apply_discount":
return { ...state, discount: action.payload };
default:
return state;
}
}15. Complex State Management đī¸
useReducer shines when a single user action affects multiple related pieces of state at once, something that's awkward to express cleanly with several separate useState calls.
Code Snippet
function formReducer(state, action) {
switch (action.type) {
case "submit_start":
return { ...state, isSubmitting: true, error: null };
case "submit_success":
return { ...state, isSubmitting: false, isSubmitted: true };
case "submit_error":
return { ...state, isSubmitting: false, error: action.payload };
default:
return state;
}
}16. Managing Objects đ§ą
Code Snippet
function userReducer(state, action) {
switch (action.type) {
case "update_field":
return { ...state, [action.field]: action.value };
default:
return state;
}
}
// Usage
dispatch({ type: "update_field", field: "email", value: "new@email.com" });Danger
17. Managing Arrays đ
Code Snippet
function listReducer(state, action) {
switch (action.type) {
case "add":
return [...state, action.payload];
case "remove":
return state.filter((item) => item.id !== action.payload.id);
case "update":
return state.map((item) =>
item.id === action.payload.id ? { ...item, ...action.payload } : item
);
default:
return state;
}
}18. Nested State Updates đĒ
Code Snippet
function settingsReducer(state, action) {
switch (action.type) {
case "toggle_notification":
return {
...state,
notifications: {
...state.notifications,
[action.channel]: !state.notifications[action.channel],
},
};
default:
return state;
}
}Tip
19. Combining Reducers đ
For large applications, splitting one large reducer into smaller, focused reducers â each handling a specific slice of state â improves readability and maintainability.
Code Snippet
function todosReducer(state, action) {
switch (action.type) {
case "add_todo": return [...state, action.payload];
default: return state;
}
}
function filterReducer(state, action) {
switch (action.type) {
case "set_filter": return action.payload;
default: return state;
}
}
function rootReducer(state, action) {
return {
todos: todosReducer(state.todos, action),
filter: filterReducer(state.filter, action),
};
}20. Reducer Composition đ§Ŧ
Composing reducers means each smaller reducer only manages its own slice of the overall state, while a root reducer coordinates them together â a pattern borrowed directly from Redux.
Reference
21. useReducer with useContext đ
Combining useReducer with Context creates a lightweight, built-in pattern for sharing complex state and its dispatch function across many components.
Code Snippet
const TodosContext = createContext(null);
function TodosProvider({ children }) {
const [state, dispatch] = useReducer(todosReducer, []);
return (
<TodosContext.Provider value={{ state, dispatch }}>
{children}
</TodosContext.Provider>
);
}
function useTodos() {
return useContext(TodosContext);
}
function AddTodoButton() {
const { dispatch } = useTodos();
return (
<button onClick={() => dispatch({ type: "add_todo", payload: { text: "New" } })}>
Add Todo
</button>
);
}22. Global State Management đ
useReducer combined with Context can serve as a lightweight alternative to external state management libraries for moderately complex applications.
| Approach | Best For |
|---|---|
| useReducer + Context | Moderate complexity, no extra dependencies needed |
| Redux / Zustand | Large-scale apps needing middleware, devtools, or fine-grained subscriptions |
23. Async Operations with Reducers âŗ
Reducers themselves must stay pure and synchronous â async logic like API calls belongs outside the reducer, typically in an effect or event handler that dispatches actions based on the result.
Code Snippet
function reducer(state, action) {
switch (action.type) {
case "fetch_start": return { ...state, loading: true, error: null };
case "fetch_success": return { ...state, loading: false, data: action.payload };
case "fetch_error": return { ...state, loading: false, error: action.payload };
default: return state;
}
}
function useUserData(userId) {
const [state, dispatch] = useReducer(reducer, { loading: false, data: null, error: null });
useEffect(() => {
dispatch({ type: "fetch_start" });
fetchUser(userId)
.then((data) => dispatch({ type: "fetch_success", payload: data }))
.catch((error) => dispatch({ type: "fetch_error", payload: error.message }));
}, [userId]);
return state;
}Important
24. Performance Considerations âĄ
- dispatch has a stable identity, making it safe to pass down to deeply nested components without causing extra re-renders.
- Combine with Context carefully â all consumers still re-render when the Provider's value changes.
- Split state into multiple reducers/contexts if different parts update at very different frequencies.
Tip
25. TypeScript with useReducer đˇ
Code Snippet
interface State {
count: number;
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "set"; payload: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment": return { count: state.count + 1 };
case "decrement": return { count: state.count - 1 };
case "set": return { count: action.payload };
default: return state;
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 });Tip
26. Best Practices đ
- Keep reducer functions pure â no side effects, no mutations, no randomness.
- Use descriptive, past-tense-free action type names (e.g., "add_todo", not "todoAdded" or "addTodo!") consistently.
- Handle async logic outside the reducer, dispatching actions based on results.
- Split large reducers into smaller, composed reducers as complexity grows.
- Pair with Context when multiple components need access to the same state and dispatch function.
27. Common Mistakes đĢ
- Mutating state directly inside the reducer instead of returning a new object or array.
- Performing side effects (API calls, timers) directly inside the reducer function.
- Forgetting a default case, silently returning undefined for unrecognized actions.
- Overusing useReducer for genuinely simple state that would be clearer as a useState call.
- Dispatching actions with inconsistent or misspelled type strings, especially without constants.
Danger
28. Frequently Asked Questions â
Question
Answer
Question
Answer
Question
Answer
29. Summary đ
useReducer centralizes complex state logic into a single, predictable, pure function, making state transitions easier to reason about and test. Combined with Context, it forms a powerful, dependency-free pattern for managing shared application state at scale.