⚙️ useReducer Hook
Welcome to Module 5! You've mastered useState, but what happens when your state logic gets complicated? When you have multiple pieces of related state that need to update together? When the next state depends on the previous state in complex ways? That's where useReducer shines! The useReducer Hook is like useState's more sophisticated older sibling - it helps you manage complex state logic in a predictable, testable way. If you've ever felt overwhelmed by managing multiple useState calls or struggled with state updates that depend on each other, you're about to discover a powerful solution. Let's level up your state management game! 🚀
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Understand when to choose useReducer over useState
- Grasp the reducer pattern and its benefits
- Create reducer functions with proper TypeScript typing
- Define action types and action creators
- Implement useReducer for complex state management
- Handle multiple related state updates with a single reducer
- Type reducers, actions, and state correctly with TypeScript
- Refactor useState logic to useReducer when appropriate
- Debug reducer logic and understand common patterns
Estimated Time: 75-90 minutes
Project: Refactor a todo application from useState to useReducer
📑 In This Lesson
⚙️ Introduction to useReducer
The useReducer Hook is an alternative to useState for managing state in React components. While useState is perfect for simple state, useReducer excels when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one in non-trivial ways.
📖 Definition
useReducer: A React Hook that lets you manage state through a reducer function. It accepts a reducer function and an initial state, and returns the current state paired with a dispatch function to trigger state updates.
Why useReducer?
Think of useReducer as a more structured way to manage state. Instead of directly setting state values, you dispatch actions that describe what happened, and a reducer function determines how the state should change. This pattern brings several benefits:
✅ Benefits of useReducer
- Predictable State Updates - All state changes happen in one place (the reducer)
- Easier to Test - Reducers are pure functions that are simple to unit test
- Better for Complex Logic - When state updates involve multiple pieces of state
- Action History - Easy to log actions for debugging or implement undo/redo
- Centralized Logic - All state update logic lives in the reducer, not scattered across components
- Type Safety - TypeScript can ensure you're dispatching valid actions
Real-World Analogy
Think of useReducer like a restaurant kitchen:
| Restaurant | useReducer |
|---|---|
| Customer (You) | Your component |
| Order (Menu Item) | Action (describes what should happen) |
| Waiter | Dispatch function |
| Chef & Kitchen | Reducer function |
| Prepared Food | New state |
You (the component) don't go into the kitchen and cook the food yourself (directly modify state). Instead, you give an order to the waiter (dispatch an action), who takes it to the kitchen (reducer), where the chef (reducer logic) prepares your meal (calculates new state) following a recipe (reducer function logic). The result comes back to you (updated state triggers re-render).
⚡ Interactive useReducer Cycle
Click "Dispatch Action" to see how useReducer works step by step
👆 Click "Dispatch INCREMENT" to see how useReducer processes an action and updates state.
💡 Key Concept
The fundamental idea behind useReducer is separation of concerns: your component describes what should happen (by dispatching actions), while the reducer specifies how the state should change. This makes your code more maintainable and easier to reason about.
The useReducer Signature
Here's what useReducer looks like at a glance:
const [state, dispatch] = useReducer(reducer, initialState);
Let's break down each part:
| Part | Description |
|---|---|
state |
The current state value (like useState) |
dispatch |
Function to trigger state updates by sending actions |
reducer |
Function that takes (state, action) and returns new state |
initialState |
The starting state value |
🔄 The Reducer Pattern
Before diving into useReducer specifically, let's understand the reducer pattern itself. This pattern comes from functional programming and is used in many contexts, including Redux, Array.reduce(), and now React Hooks.
📖 Definition
Reducer: A pure function that takes the current state and an action, then returns a new state. The name "reducer" comes from Array.reduce() - it "reduces" a collection of actions over time into a single state value.
Anatomy of a Reducer
Every reducer follows this basic structure:
function reducer(state, action) {
// Examine the action
switch (action.type) {
case 'ACTION_TYPE_1':
// Return new state based on this action
return { ...state, /* changes */ };
case 'ACTION_TYPE_2':
// Return new state for different action
return { ...state, /* changes */ };
default:
// Always return current state for unknown actions
return state;
}
}
✅ Reducer Rules
Reducers must follow these important rules:
- Pure Function - Same inputs always produce same output, no side effects
- No Mutations - Never modify state directly; always return a new state object
- Synchronous - No async operations, promises, or API calls inside reducers
- Return State - Always return a state value (even if unchanged)
Understanding Actions
Actions are plain JavaScript objects that describe what happened. By convention, actions have a type property that identifies the action, and often include additional data:
// Simple action with just a type
{ type: 'INCREMENT' }
// Action with additional data (payload)
{ type: 'ADD_TODO', payload: { id: 1, text: 'Learn useReducer' } }
// Action with multiple properties
{
type: 'UPDATE_USER',
id: 123,
name: 'John Doe',
email: 'john@example.com'
}
💡 Naming Convention
Action types are typically written in SCREAMING_SNAKE_CASE (all uppercase with underscores). This makes them stand out in your code and clearly indicates they're constants.
The Reducer Flow
Here's how data flows through a reducer:
🔄 Interactive Reducer Flow
Click on each step to see how the reducer pattern processes actions
👆 Click on any step (1-4) to understand how the reducer pattern works.
Example: Array.reduce() vs Reducer Pattern
To understand why it's called a "reducer," let's see how Array.reduce() works similarly:
// Array.reduce() example
const numbers = [1, 2, 3, 4, 5];
const sum = numbers.reduce(
(accumulator, currentValue) => accumulator + currentValue,
0 // initial value
);
// Result: 15
// This is conceptually similar to:
// state = 0 (initial)
// action = 1 → state = 0 + 1 = 1
// action = 2 → state = 1 + 2 = 3
// action = 3 → state = 3 + 3 = 6
// action = 4 → state = 6 + 4 = 10
// action = 5 → state = 10 + 5 = 15
// useReducer does the same thing, but with actions over time!
const counterReducer = (state, action) => {
if (action.type === 'INCREMENT') {
return state + 1;
}
return state;
};
// dispatch({ type: 'INCREMENT' }) → state goes from 0 to 1
// dispatch({ type: 'INCREMENT' }) → state goes from 1 to 2
// dispatch({ type: 'INCREMENT' }) → state goes from 2 to 3
⚠️ Important Insight
Both Array.reduce() and useReducer "reduce" a collection of values into a single result. Array.reduce() processes an array of values immediately, while useReducer processes actions over time as they're dispatched. The pattern is the same!
⚖️ useState vs useReducer
Both useState and useReducer manage state, but they're suited for different scenarios. Understanding when to use each is key to writing clean, maintainable React code.
When to Use useState
✅ Use useState When:
- State is simple (single primitive value or simple object)
- State updates are independent of each other
- Few state variables (typically 1-3)
- State update logic is straightforward
- No complex relationships between state values
Example Scenarios:
- Toggle state (boolean)
- Form input value (string)
- Counter (number)
- Single selection (string or number)
When to Use useReducer
💡 Use useReducer When:
- State is complex (multiple related sub-values)
- Next state depends on previous state in complex ways
- Many state updates from different actions
- State update logic is getting complicated
- You want to separate state logic from component logic
- Testing state logic independently is important
Example Scenarios:
- Todo list with add, remove, toggle, filter operations
- Shopping cart with add, remove, update quantity, apply coupon
- Form with multiple fields and validation rules
- Multi-step wizard with navigation and data
- Complex UI state (modals, tabs, accordions)
Side-by-Side Comparison
Let's see the same counter implemented with both hooks:
With useState
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<button onClick={() => setCount(count - 1)}>
Decrement
</button>
<button onClick={() => setCount(0)}>
Reset
</button>
</div>
);
}
With useReducer
import { useReducer } from 'react';
// Reducer function (lives outside component)
function counterReducer(state: number, action: { type: string }) {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
case 'RESET':
return 0;
default:
return state;
}
}
function Counter() {
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
Increment
</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>
Decrement
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>
Reset
</button>
</div>
);
}
For this simple counter, useState is actually better - less code, more straightforward. But let's see what happens when we add more complexity:
Complex Counter with useState
function ComplexCounter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
const [history, setHistory] = useState<number[]>([0]);
const increment = () => {
const newCount = count + step;
setCount(newCount);
setHistory([...history, newCount]);
};
const decrement = () => {
const newCount = count - step;
setCount(newCount);
setHistory([...history, newCount]);
};
const reset = () => {
setCount(0);
setHistory([0]);
};
const undo = () => {
if (history.length > 1) {
const newHistory = history.slice(0, -1);
setHistory(newHistory);
setCount(newHistory[newHistory.length - 1]);
}
};
// This is getting messy! Three related state values,
// and we have to keep them in sync manually.
}
Complex Counter with useReducer
interface CounterState {
count: number;
step: number;
history: number[];
}
type CounterAction =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET' }
| { type: 'UNDO' }
| { type: 'SET_STEP'; payload: number };
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'INCREMENT': {
const newCount = state.count + state.step;
return {
...state,
count: newCount,
history: [...state.history, newCount]
};
}
case 'DECREMENT': {
const newCount = state.count - state.step;
return {
...state,
count: newCount,
history: [...state.history, newCount]
};
}
case 'RESET':
return {
...state,
count: 0,
history: [0]
};
case 'UNDO':
if (state.history.length > 1) {
const newHistory = state.history.slice(0, -1);
return {
...state,
count: newHistory[newHistory.length - 1],
history: newHistory
};
}
return state;
case 'SET_STEP':
return {
...state,
step: action.payload
};
default:
return state;
}
}
function ComplexCounter() {
const [state, dispatch] = useReducer(counterReducer, {
count: 0,
step: 1,
history: [0]
});
// Much cleaner component code!
// All the complex logic is in the reducer.
}
✅ Key Takeaway
As your state becomes more complex, useReducer helps you:
- Keep related state values together
- Ensure state updates are consistent
- Make the component code cleaner
- Test state logic independently
- Scale without the mess
Decision Tree
Use this decision tree to choose between useState and useReducer:
📊 Interactive: Watch Complexity Grow
Click "Add Feature" to see how useState becomes harder to manage as complexity increases
- const [count, setCount] = useState(0)
- dispatch({ type: 'INCREMENT' })
📝 Basic useReducer Syntax
Now that you understand the concepts, let's dive into the actual syntax of useReducer. We'll start simple and build up complexity gradually.
The Basic Pattern
Here's the fundamental structure of using useReducer:
import { useReducer } from 'react';
// Step 1: Define your reducer function
function reducer(state, action) {
switch (action.type) {
case 'ACTION_NAME':
return newState;
default:
return state;
}
}
// Step 2: Use it in your component
function MyComponent() {
const [state, dispatch] = useReducer(reducer, initialState);
// Step 3: Dispatch actions to update state
const handleClick = () => {
dispatch({ type: 'ACTION_NAME' });
};
return (
<div>
{/* Use state and dispatch in your JSX */}
</div>
);
}
Breaking Down the Parts
Let's examine each piece in detail:
1. The Reducer Function
function reducer(state, action) {
// state: current state value
// action: object describing what happened
// Return the new state based on the action
return newState;
}
The reducer function:
- Takes two parameters: current
stateand anaction - Returns the new state (or current state if action is unrecognized)
- Is typically defined outside the component (so it's not recreated on every render)
- Should be a pure function (no side effects)
2. The useReducer Call
const [state, dispatch] = useReducer(reducer, initialState);
| Parameter | Description | Example |
|---|---|---|
reducer |
The reducer function | counterReducer |
initialState |
Starting state value | 0 or { count: 0 } |
state |
Current state (returned) | Use like {state.count} |
dispatch |
Function to trigger updates (returned) | dispatch({ type: 'INCREMENT' }) |
3. Dispatching Actions
// Simple action
dispatch({ type: 'INCREMENT' });
// Action with data
dispatch({ type: 'SET_NAME', payload: 'John' });
// Action with multiple properties
dispatch({
type: 'UPDATE_USER',
id: 123,
name: 'John Doe'
});
💡 Understanding Dispatch
The dispatch function is stable - it doesn't change between renders. This makes it safe to pass to child components or include in dependency arrays. React guarantees the same dispatch function will be used throughout the component's lifetime.
Lazy Initialization (Optional)
Sometimes calculating the initial state is expensive. You can pass a third parameter - an init function:
// Expensive initial state calculation
function init(initialCount: number) {
// This only runs once on mount
return {
count: initialCount,
history: [initialCount],
timestamp: Date.now()
};
}
// Use it like this:
const [state, dispatch] = useReducer(reducer, 10, init);
// Now 'init' is called with 10 as argument
// Result: { count: 10, history: [10], timestamp: 1234567890 }
⚠️ When to Use Lazy Initialization
Use the init function when:
- Initial state calculation is expensive (reading from localStorage, complex computations)
- Initial state depends on props (but you want to compute it only once)
- You want to extract and reuse the initialization logic
Don't use it for simple initial states - it adds unnecessary complexity.
Complete Minimal Example
Here's a complete working example to see everything together:
import { useReducer } from 'react';
// Reducer function
function counterReducer(state: number, action: { type: string }) {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
}
// Component
function Counter() {
// Hook call
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
+1
</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>
-1
</button>
</div>
);
}
export default Counter;
✅ What's Happening Here
- Component mounts with initial state
0 - User clicks "+1" button
dispatch({ type: 'INCREMENT' })is called- Reducer receives
(0, { type: 'INCREMENT' }) - Reducer returns
1 - React updates state to
1and re-renders - Component shows "Count: 1"
🔷 Typing Reducers with TypeScript
TypeScript makes useReducer even more powerful by ensuring type safety for your state and actions. Let's learn how to properly type everything for maximum safety and great developer experience.
Why Type Your Reducers?
✅ Benefits of Typed Reducers
- Catch Errors Early - TypeScript will warn you about invalid action types
- Autocomplete - Your editor suggests valid action types and payloads
- Refactoring Safety - Rename actions and TypeScript finds all usages
- Documentation - Types serve as documentation for your state shape
- Confidence - Know your reducer handles all cases correctly
Basic Typing Pattern
Here's the fundamental pattern for typing reducers:
// 1. Define your state type
interface State {
// your state properties
}
// 2. Define your action types
type Action =
| { type: 'ACTION_ONE' }
| { type: 'ACTION_TWO', payload: string }
| { type: 'ACTION_THREE', payload: number };
// 3. Type your reducer
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'ACTION_ONE':
return { ...state };
case 'ACTION_TWO':
// TypeScript knows action.payload is a string here!
return { ...state, value: action.payload };
case 'ACTION_THREE':
// TypeScript knows action.payload is a number here!
return { ...state, count: action.payload };
default:
return state;
}
}
// 4. Use with typed state and dispatch
const [state, dispatch] = useReducer(reducer, initialState);
Step-by-Step Example: Typed Counter
Let's build a fully-typed counter with multiple operations:
Step 1: Define State Type
interface CounterState {
count: number;
step: number;
}
Step 2: Define Action Types
// Using discriminated union types
type CounterAction =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET' }
| { type: 'SET_STEP'; step: number }
| { type: 'SET_COUNT'; count: number };
// TypeScript now knows all possible actions!
💡 Discriminated Unions
The pipe (|) operator creates a union type - the action can be any one of these types. TypeScript uses the type property to discriminate between them, giving you perfect autocomplete and type checking!
Step 3: Type the Reducer
function counterReducer(
state: CounterState,
action: CounterAction
): CounterState {
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 'SET_STEP':
// TypeScript knows action.step exists and is a number!
return {
...state,
step: action.step
};
case 'SET_COUNT':
// TypeScript knows action.count exists and is a number!
return {
...state,
count: action.count
};
default:
// This ensures we handle all action types
const exhaustiveCheck: never = action;
return state;
}
}
⚠️ The Exhaustive Check Pattern
The const exhaustiveCheck: never = action; line is a clever TypeScript pattern. If you forget to handle an action type, TypeScript will error because action won't be never. This ensures you handle all cases!
Step 4: Use in Component
import { useReducer } from 'react';
function Counter() {
const [state, dispatch] = useReducer(counterReducer, {
count: 0,
step: 1
});
return (
<div>
<p>Count: {state.count}</p>
<p>Step: {state.step}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
+ {state.step}
</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>
- {state.step}
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>
Reset
</button>
<input
type="number"
value={state.step}
onChange={(e) => dispatch({
type: 'SET_STEP',
step: Number(e.target.value)
})}
/>
{/* TypeScript will error if you dispatch invalid action! */}
{/* dispatch({ type: 'INVALID' }) // ❌ Error! */}
{/* dispatch({ type: 'SET_STEP' }) // ❌ Error! Missing 'step' */}
</div>
);
}
Advanced Typing Patterns
Pattern 1: Using Constants for Action Types
// Define action type constants
const INCREMENT = 'INCREMENT' as const;
const DECREMENT = 'DECREMENT' as const;
const SET_COUNT = 'SET_COUNT' as const;
// Use them in your action types
type CounterAction =
| { type: typeof INCREMENT }
| { type: typeof DECREMENT }
| { type: typeof SET_COUNT; count: number };
// Benefits: Refactor-safe, reusable, no magic strings
dispatch({ type: INCREMENT }); // Works!
dispatch({ type: 'INCREMENT' }); // Also works!
Pattern 2: Payload Convention
// Many developers use 'payload' for action data
type TodoAction =
| { type: 'ADD_TODO'; payload: { text: string } }
| { type: 'TOGGLE_TODO'; payload: { id: number } }
| { type: 'DELETE_TODO'; payload: { id: number } };
// Usage
dispatch({
type: 'ADD_TODO',
payload: { text: 'Learn useReducer' }
});
Pattern 3: Generic Reducer Type
// Create a generic reducer type
type Reducer<S, A> = (state: S, action: A) => S;
// Use it to type your reducer
const counterReducer: Reducer<CounterState, CounterAction> = (state, action) => {
// TypeScript infers parameter types!
switch (action.type) {
// ...
}
};
Type Inference with useReducer
TypeScript can infer types from your reducer, but explicit typing is often better:
// TypeScript infers types from reducer
const [state, dispatch] = useReducer(counterReducer, { count: 0, step: 1 });
// state is inferred as CounterState
// dispatch is inferred as Dispatch<CounterAction>
// You can also be explicit (recommended for clarity)
const [state, dispatch] = useReducer<CounterState, CounterAction>(
counterReducer,
{ count: 0, step: 1 }
);
✅ TypeScript Typing Best Practices
- Always type your state interface/type
- Use discriminated unions for actions
- Include the exhaustive check in default case
- Use
as constfor action type constants - Consider using payload convention for consistency
- Let TypeScript infer types where it's clear
🎬 Actions and Action Types
Actions are the messages you send to your reducer to describe what should happen. Understanding how to structure actions effectively is key to writing clean, maintainable code with useReducer.
Anatomy of an Action
An action is just a plain JavaScript object, but it follows certain conventions:
// Minimal action - just a type
{ type: 'RESET' }
// Action with data
{
type: 'ADD_TODO',
payload: {
id: 1,
text: 'Learn useReducer',
completed: false
}
}
// Action with multiple properties (alternative to payload)
{
type: 'UPDATE_USER',
id: 123,
name: 'John Doe',
email: 'john@example.com'
}
📖 Action Structure
Required:
type- A string describing the action (REQUIRED)
Optional:
payload- Data needed for the action (COMMON)- Other properties - Additional data (LESS COMMON)
Naming Action Types
Good action type names make your code self-documenting. Follow these conventions:
| Convention | Example | Why? |
|---|---|---|
| SCREAMING_SNAKE_CASE | ADD_TODO |
Standard in Redux, stands out as constant |
| Verb + Noun | FETCH_USER |
Describes the action clearly |
| Past tense events | USER_LOADED |
For completed actions/events |
| Domain prefixes | TODO_ADDED |
Organizes actions by feature |
// ✅ Good action type names
'ADD_TODO' // Clear action
'TOGGLE_TODO' // Specific operation
'DELETE_TODO' // No ambiguity
'FILTER_TODOS' // Describes intent
'SET_LOADING' // Clear state change
// ❌ Poor action type names
'TODO' // What about todo?
'UPDATE' // Update what?
'CHANGE' // Too vague
'CLICK' // Describes UI, not intent
'handleClick' // Not a constant name
Action Creator Functions
Instead of manually creating action objects everywhere, use action creator functions:
// Without action creators (not recommended)
dispatch({ type: 'ADD_TODO', payload: { id: 1, text: 'Learn', completed: false } });
dispatch({ type: 'ADD_TODO', payload: { id: 2, text: 'Practice', completed: false } });
// With action creators (recommended!)
function addTodo(id: number, text: string) {
return {
type: 'ADD_TODO' as const,
payload: {
id,
text,
completed: false
}
};
}
// Usage - much cleaner!
dispatch(addTodo(1, 'Learn'));
dispatch(addTodo(2, 'Practice'));
✅ Benefits of Action Creators
- Consistency - All actions have the same structure
- Reusability - One function, many uses
- Type Safety - TypeScript checks parameters
- Testability - Easy to test action creation
- Refactoring - Change structure in one place
- Documentation - Function signature documents required data
Complete Action Creators Example
// Action types
type TodoAction =
| ReturnType<typeof addTodo>
| ReturnType<typeof toggleTodo>
| ReturnType<typeof deleteTodo>
| ReturnType<typeof updateTodoText>;
// Action creators
function addTodo(text: string) {
return {
type: 'ADD_TODO' as const,
payload: {
id: Date.now(),
text,
completed: false
}
};
}
function toggleTodo(id: number) {
return {
type: 'TOGGLE_TODO' as const,
payload: { id }
};
}
function deleteTodo(id: number) {
return {
type: 'DELETE_TODO' as const,
payload: { id }
};
}
function updateTodoText(id: number, text: string) {
return {
type: 'UPDATE_TODO_TEXT' as const,
payload: { id, text }
};
}
// Usage in component
function TodoList() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const handleAdd = (text: string) => {
dispatch(addTodo(text));
};
const handleToggle = (id: number) => {
dispatch(toggleTodo(id));
};
// TypeScript ensures you pass correct parameters!
// dispatch(addTodo()); // ❌ Error: missing text parameter
// dispatch(toggleTodo('hello')); // ❌ Error: id must be number
}
💡 ReturnType Utility
The ReturnType<typeof function> utility type extracts the return type of a function. This lets TypeScript infer your action types from your action creators automatically - no duplicate type definitions!
Action Patterns
Pattern 1: Simple Actions (No Data)
// For actions that don't need data
const reset = () => ({ type: 'RESET' as const });
const clearAll = () => ({ type: 'CLEAR_ALL' as const });
dispatch(reset());
dispatch(clearAll());
Pattern 2: Payload Actions
// For actions with a single piece of data
const setName = (name: string) => ({
type: 'SET_NAME' as const,
payload: name
});
const setAge = (age: number) => ({
type: 'SET_AGE' as const,
payload: age
});
dispatch(setName('John'));
dispatch(setAge(30));
Pattern 3: Object Payload Actions
// For actions with complex data
const updateUser = (updates: Partial<User>) => ({
type: 'UPDATE_USER' as const,
payload: updates
});
dispatch(updateUser({
name: 'John Doe',
email: 'john@example.com'
}));
Pattern 4: Multiple Property Actions
// Alternative to payload (less common but valid)
const moveItem = (from: number, to: number) => ({
type: 'MOVE_ITEM' as const,
from,
to
});
dispatch(moveItem(0, 3));
⚠️ Action Best Practices
- Keep actions simple - they describe what happened, not how to handle it
- Use action creators for consistency
- Name actions descriptively (verb + noun)
- Keep payload data minimal - only what's needed
- Don't put functions or promises in actions
- Group related actions with prefixes (TODO_ADD, TODO_DELETE)
🔢 Simple Counter Example
Let's put everything we've learned together by building a complete counter application with useReducer. We'll start simple and gradually add features to see how useReducer scales.
Basic Counter
Here's a simple counter with increment and decrement:
import { useReducer } from 'react';
// State type
type CounterState = number;
// Action types
type CounterAction =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET' };
// Reducer function
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
case 'RESET':
return 0;
default:
return state;
}
}
// Component
function Counter() {
const [count, dispatch] = useReducer(counterReducer, 0);
return (
<div className="counter">
<h2>Counter</h2>
<p className="count-display">{count}</p>
<div className="button-group">
<button onClick={() => dispatch({ type: 'DECREMENT' })}>
-1
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>
Reset
</button>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
+1
</button>
</div>
</div>
);
}
export default Counter;
✅ What We Have
- Type-safe state and actions
- Clear separation: reducer handles logic, component handles UI
- Three actions: INCREMENT, DECREMENT, RESET
- Simple initial state (0)
Enhanced Counter with Step
Let's add the ability to change the increment/decrement step:
import { useReducer } from 'react';
// State type - now an object with count and step
interface CounterState {
count: number;
step: number;
}
// Action types - added SET_STEP
type CounterAction =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET' }
| { type: 'SET_STEP'; payload: number };
// Reducer - handles multiple pieces of state
function counterReducer(state: CounterState, action: CounterAction): CounterState {
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 'SET_STEP':
return {
...state,
step: action.payload
};
default:
return state;
}
}
// Component
function Counter() {
const [state, dispatch] = useReducer(counterReducer, {
count: 0,
step: 1
});
return (
<div className="counter">
<h2>Enhanced Counter</h2>
<p className="count-display">Count: {state.count}</p>
<p className="step-display">Step: {state.step}</p>
<div className="button-group">
<button onClick={() => dispatch({ type: 'DECREMENT' })}>
-{state.step}
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>
Reset
</button>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
+{state.step}
</button>
</div>
<div className="step-control">
<label>
Change Step:
<input
type="number"
value={state.step}
onChange={(e) => dispatch({
type: 'SET_STEP',
payload: Number(e.target.value)
})}
min="1"
/>
</label>
</div>
</div>
);
}
export default Counter;
💡 Key Improvements
- Object State - Now tracking two related values (count and step)
- Spread Operator - Using
...stateto preserve other state values - Dynamic Step - Increment/decrement by user-defined amount
- Single Reducer - All state logic in one place
Advanced Counter with History
Now let's add undo functionality by tracking history:
import { useReducer } from 'react';
// State type with history
interface CounterState {
count: number;
step: number;
history: number[];
}
// Action types with undo
type CounterAction =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET' }
| { type: 'SET_STEP'; payload: number }
| { type: 'UNDO' };
// Reducer with history management
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'INCREMENT': {
const newCount = state.count + state.step;
return {
...state,
count: newCount,
history: [...state.history, newCount]
};
}
case 'DECREMENT': {
const newCount = state.count - state.step;
return {
...state,
count: newCount,
history: [...state.history, newCount]
};
}
case 'RESET':
return {
...state,
count: 0,
history: [0]
};
case 'SET_STEP':
return {
...state,
step: action.payload
};
case 'UNDO':
// Can't undo if only initial value in history
if (state.history.length <= 1) {
return state;
}
// Remove last history entry
const newHistory = state.history.slice(0, -1);
// Get the previous count value
const previousCount = newHistory[newHistory.length - 1];
return {
...state,
count: previousCount,
history: newHistory
};
default:
return state;
}
}
// Component
function Counter() {
const [state, dispatch] = useReducer(counterReducer, {
count: 0,
step: 1,
history: [0]
});
const canUndo = state.history.length > 1;
return (
<div className="counter">
<h2>Counter with History</h2>
<p className="count-display">Count: {state.count}</p>
<p className="step-display">Step: {state.step}</p>
<div className="button-group">
<button onClick={() => dispatch({ type: 'DECREMENT' })}>
-{state.step}
</button>
<button onClick={() => dispatch({ type: 'RESET' })}>
Reset
</button>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>
+{state.step}
</button>
<button
onClick={() => dispatch({ type: 'UNDO' })}
disabled={!canUndo}
>
↶ Undo
</button>
</div>
<div className="step-control">
<label>
Change Step:
<input
type="number"
value={state.step}
onChange={(e) => dispatch({
type: 'SET_STEP',
payload: Number(e.target.value)
})}
min="1"
/>
</label>
</div>
<div className="history">
<h3>History</h3>
<p>{state.history.join(' → ')}</p>
</div>
</div>
);
}
export default Counter;
✅ Advanced Features
- History Tracking - Array of all previous counts
- Undo Capability - Go back to previous state
- UI State - Disable undo when no history
- Consistent Updates - History updates with every change
This would be very messy with multiple useState calls! useReducer keeps everything organized.
⚠️ Notice the Pattern
See how we use curly braces { } in some case blocks? This creates a block scope, allowing us to declare variables like newCount without conflicts between cases. Without the braces, you'd get "variable already declared" errors!
📝 Complex State Example: Todo List
Now let's tackle a real-world example that truly showcases useReducer's power: a todo list with multiple features. This example will demonstrate why useReducer is superior to useState for complex state.
Todo List Requirements
Features We'll Build
- ➕ Add new todos
- ✓ Toggle todo completion
- 🗑️ Delete todos
- ✏️ Edit todo text
- 🔽 Filter todos (all, active, completed)
- 🧹 Clear completed todos
Step 1: Define Types
// Todo item type
interface Todo {
id: number;
text: string;
completed: boolean;
}
// Filter type
type FilterType = 'all' | 'active' | 'completed';
// State type
interface TodoState {
todos: Todo[];
filter: FilterType;
}
// Action types
type TodoAction =
| { type: 'ADD_TODO'; payload: { text: string } }
| { type: 'TOGGLE_TODO'; payload: { id: number } }
| { type: 'DELETE_TODO'; payload: { id: number } }
| { type: 'EDIT_TODO'; payload: { id: number; text: string } }
| { type: 'SET_FILTER'; payload: { filter: FilterType } }
| { type: 'CLEAR_COMPLETED' };
Step 2: Create Action Creators
// Action creators
const addTodo = (text: string) => ({
type: 'ADD_TODO' as const,
payload: { text }
});
const toggleTodo = (id: number) => ({
type: 'TOGGLE_TODO' as const,
payload: { id }
});
const deleteTodo = (id: number) => ({
type: 'DELETE_TODO' as const,
payload: { id }
});
const editTodo = (id: number, text: string) => ({
type: 'EDIT_TODO' as const,
payload: { id, text }
});
const setFilter = (filter: FilterType) => ({
type: 'SET_FILTER' as const,
payload: { filter }
});
const clearCompleted = () => ({
type: 'CLEAR_COMPLETED' as const
});
Step 3: Implement Reducer
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: Date.now(),
text: action.payload.text,
completed: false
}
]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload.id)
};
case 'EDIT_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, text: action.payload.text }
: todo
)
};
case 'SET_FILTER':
return {
...state,
filter: action.payload.filter
};
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter(todo => !todo.completed)
};
default:
return state;
}
}
💡 Reducer Patterns Used
- Immutable Updates - Using spread operator and array methods
- Array Mapping - Update specific items without mutation
- Array Filtering - Remove items by creating new array
- Conditional Updates - Only update matching items
📝 Interactive Todo State Explorer
Click actions to see how the reducer transforms state immutably
Step 4: Build Component
import { useReducer, useState } from 'react';
function TodoList() {
const [state, dispatch] = useReducer(todoReducer, {
todos: [],
filter: 'all'
});
const [inputValue, setInputValue] = useState('');
// Filter todos based on current filter
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true; // 'all'
});
// Handle add todo
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputValue.trim()) {
dispatch(addTodo(inputValue));
setInputValue('');
}
};
// Count active todos
const activeTodoCount = state.todos.filter(t => !t.completed).length;
return (
<div className="todo-app">
<h1>📝 Todo List</h1>
{/* Add Todo Form */}
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
{/* Filter Buttons */}
<div className="filters">
<button
onClick={() => dispatch(setFilter('all'))}
className={state.filter === 'all' ? 'active' : ''}
>
All ({state.todos.length})
</button>
<button
onClick={() => dispatch(setFilter('active'))}
className={state.filter === 'active' ? 'active' : ''}
>
Active ({activeTodoCount})
</button>
<button
onClick={() => dispatch(setFilter('completed'))}
className={state.filter === 'completed' ? 'active' : ''}
>
Completed ({state.todos.length - activeTodoCount})
</button>
</div>
{/* Todo List */}
<ul className="todo-list">
{filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => dispatch(toggleTodo(todo.id))}
onDelete={() => dispatch(deleteTodo(todo.id))}
onEdit={(text) => dispatch(editTodo(todo.id, text))}
/>
))}
</ul>
{/* Footer */}
{state.todos.length > 0 && (
<div className="footer">
<span>{activeTodoCount} item(s) left</span>
{activeTodoCount < state.todos.length && (
<button onClick={() => dispatch(clearCompleted())}>
Clear Completed
</button>
)}
</div>
)}
</div>
);
}
export default TodoList;
Step 5: TodoItem Component
import { useState } from 'react';
interface TodoItemProps {
todo: Todo;
onToggle: () => void;
onDelete: () => void;
onEdit: (text: string) => void;
}
function TodoItem({ todo, onToggle, onDelete, onEdit }: TodoItemProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(todo.text);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editValue.trim()) {
onEdit(editValue);
setIsEditing(false);
}
};
const handleCancel = () => {
setEditValue(todo.text);
setIsEditing(false);
};
if (isEditing) {
return (
<li className="todo-item editing">
<form onSubmit={handleSubmit}>
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
/>
<button type="submit">Save</button>
<button type="button" onClick={handleCancel}>
Cancel
</button>
</form>
</li>
);
}
return (
<li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={onToggle}
/>
<span
onDoubleClick={() => setIsEditing(true)}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.text}
</span>
<button onClick={onDelete}>Delete</button>
</li>
);
}
export default TodoItem;
✅ What We Achieved
This todo app demonstrates:
- Complex State Management - Todos array + filter state
- Multiple Operations - 6 different actions
- Derived State - Filtered todos, counts
- Component Composition - TodoItem separated for clarity
- Type Safety - Full TypeScript coverage
- Clean Code - Component focuses on UI, reducer handles logic
Comparing useState vs useReducer
Imagine implementing this todo app with useState:
// With useState - messy and error-prone!
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<FilterType>('all');
const addTodo = (text: string) => {
setTodos([...todos, {
id: Date.now(),
text,
completed: false
}]);
};
const toggleTodo = (id: number) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const deleteTodo = (id: number) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// ... more functions scattered throughout component
// Logic is mixed with UI code
// Harder to test
// More prone to stale closure bugs
}
// With useReducer - clean and organized!
function TodoList() {
const [state, dispatch] = useReducer(todoReducer, initialState);
// All logic is in reducer
// Component focuses on rendering
// Easy to test reducer independently
// No stale closure issues
// Actions clearly describe intent
}
💡 Key Takeaway
For the todo app, useReducer provides:
- Better Organization - All state logic in one place
- Easier Testing - Test reducer functions independently
- Clearer Intent - Action types describe what's happening
- Fewer Bugs - Centralized logic prevents inconsistencies
- Scalability - Easy to add new actions
🏋️ Hands-on Practice
Now it's your turn! These exercises will help you master useReducer. Start with the easier ones and work your way up.
Exercise 1: Traffic Light Controller
🎯 Challenge
Build a traffic light controller that cycles through: 🔴 Red → 🟢 Green → 🟡 Yellow → 🔴 Red
Requirements:
- Use useReducer to manage the current light state
- Add a "Next" button to cycle to the next light
- Display the current light with appropriate colors
- Add a counter showing how many times lights have changed
💡 Hint
Your state might look like:
interface TrafficLightState {
currentLight: 'red' | 'green' | 'yellow';
changeCount: number;
}
You'll need actions like: 'NEXT_LIGHT'
✅ Solution
import { useReducer } from 'react';
type LightColor = 'red' | 'green' | 'yellow';
interface TrafficLightState {
currentLight: LightColor;
changeCount: number;
}
type TrafficLightAction = { type: 'NEXT_LIGHT' };
function trafficLightReducer(
state: TrafficLightState,
action: TrafficLightAction
): TrafficLightState {
switch (action.type) {
case 'NEXT_LIGHT': {
let nextLight: LightColor;
switch (state.currentLight) {
case 'red':
nextLight = 'green';
break;
case 'green':
nextLight = 'yellow';
break;
case 'yellow':
nextLight = 'red';
break;
}
return {
currentLight: nextLight,
changeCount: state.changeCount + 1
};
}
default:
return state;
}
}
function TrafficLight() {
const [state, dispatch] = useReducer(trafficLightReducer, {
currentLight: 'red',
changeCount: 0
});
const lightColors = {
red: '#ff0000',
green: '#00ff00',
yellow: '#ffff00'
};
return (
<div>
<h2>🚦 Traffic Light</h2>
<div
style={{
width: '100px',
height: '100px',
borderRadius: '50%',
backgroundColor: lightColors[state.currentLight],
margin: '20px auto'
}}
/>
<p>Current: {state.currentLight.toUpperCase()}</p>
<p>Changes: {state.changeCount}</p>
<button onClick={() => dispatch({ type: 'NEXT_LIGHT' })}>
Next Light
</button>
</div>
);
}
Exercise 2: Shopping Cart
🎯 Challenge
Build a shopping cart with add, remove, and quantity update functionality.
Requirements:
- Add items to cart (with name and price)
- Remove items from cart
- Increase/decrease item quantity
- Display total price
- Clear entire cart
💡 Hint
Your state might look like:
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
}
Actions: ADD_ITEM, REMOVE_ITEM, UPDATE_QUANTITY, CLEAR_CART
Exercise 3: Form Wizard
🎯 Challenge
Create a multi-step form wizard (personal info → address → confirmation).
Requirements:
- Three steps with different form fields
- Next/Previous navigation
- Form data persists across steps
- Validation before moving to next step
- Submit on final step
💡 Hint
Your state might include:
interface WizardState {
currentStep: number;
formData: {
name: string;
email: string;
address: string;
city: string;
// ... more fields
};
errors: Record<string, string>;
}
✅ Practice Tips
- Start by defining your state and action types
- Write the reducer before the component
- Test your reducer with simple console.log calls
- Add one action at a time and test it
- Use TypeScript to catch errors early
- Think about what actions users will trigger
🎨 Common Patterns
Now that you understand useReducer, let's explore common patterns that will help you write better reducer code. These patterns have been refined by the React community and are widely used in production applications.
Pattern 1: Action Creator Pattern
Always use action creator functions instead of creating action objects manually:
// ❌ Not recommended - manual action objects
dispatch({ type: 'ADD_TODO', payload: { text: 'Learn React' } });
dispatch({ type: 'ADD_TODO', payload: { text: 'Build project' } });
// ✅ Recommended - action creators
const addTodo = (text: string) => ({
type: 'ADD_TODO' as const,
payload: { text }
});
dispatch(addTodo('Learn React'));
dispatch(addTodo('Build project'));
Benefits: Type safety, consistency, easier refactoring, less repetition
Pattern 2: State Normalization
For collections, use objects instead of arrays for better performance:
// ❌ Array (slower for lookups)
interface State {
todos: Todo[];
}
// Finding a todo requires iteration
const todo = state.todos.find(t => t.id === 5);
// ✅ Normalized object (O(1) lookups)
interface State {
todos: {
byId: Record<number, Todo>;
allIds: number[];
};
}
// Direct access - much faster!
const todo = state.todos.byId[5];
When to use: Large collections (100+ items), frequent lookups, performance-critical apps
Pattern 3: Derived State
Don't store computed values in state - calculate them during render:
// ❌ Storing derived state (redundant, can get out of sync)
interface TodoState {
todos: Todo[];
completedCount: number; // Don't do this!
activeCount: number; // Don't do this!
}
// ✅ Calculate during render
interface TodoState {
todos: Todo[];
}
function TodoList() {
const [state, dispatch] = useReducer(todoReducer, initialState);
// Calculate on each render (React is fast!)
const completedCount = state.todos.filter(t => t.completed).length;
const activeCount = state.todos.length - completedCount;
// ...
}
💡 Why This Works
React renders are fast! Computing derived values on each render is usually cheaper than keeping them in sync. If calculations are truly expensive, use useMemo later.
Pattern 4: Reducer Composition
Split large reducers into smaller, focused reducers:
// Split by domain
function todosReducer(todos: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case 'ADD_TODO':
return [...todos, newTodo];
case 'TOGGLE_TODO':
return todos.map(/* ... */);
default:
return todos;
}
}
function filterReducer(filter: FilterType, action: FilterAction): FilterType {
switch (action.type) {
case 'SET_FILTER':
return action.payload;
default:
return filter;
}
}
// Combine them
function appReducer(state: AppState, action: AppAction): AppState {
return {
todos: todosReducer(state.todos, action),
filter: filterReducer(state.filter, action)
};
}
When to use: Large applications, multiple domains, team collaboration
Pattern 5: Immer for Immutable Updates
Use Immer library to write "mutable" code that's actually immutable:
# Install Immer
npm install immer use-immer
import { useImmerReducer } from 'use-immer';
// Without Immer - complex spread operators
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
}
}
// With Immer - looks like mutations but isn't!
function todoReducer(draft: TodoState, action: TodoAction) {
switch (action.type) {
case 'TOGGLE_TODO': {
const todo = draft.todos.find(t => t.id === action.payload.id);
if (todo) {
todo.completed = !todo.completed;
}
break;
}
}
}
// Use it
const [state, dispatch] = useImmerReducer(todoReducer, initialState);
✅ Immer Benefits
- Simpler, more readable code
- Less boilerplate (no spread operators everywhere)
- Still immutable under the hood
- Great for deeply nested updates
Pattern 6: Async Actions with useReducer
Handle async operations by dispatching multiple actions:
// State includes loading and error
interface DataState {
data: User | null;
loading: boolean;
error: string | null;
}
type DataAction =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User }
| { type: 'FETCH_ERROR'; payload: string };
function dataReducer(state: DataState, action: DataAction): DataState {
switch (action.type) {
case 'FETCH_START':
return {
...state,
loading: true,
error: null
};
case 'FETCH_SUCCESS':
return {
data: action.payload,
loading: false,
error: null
};
case 'FETCH_ERROR':
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
}
// In component
function UserProfile({ userId }: { userId: number }) {
const [state, dispatch] = useReducer(dataReducer, {
data: null,
loading: false,
error: null
});
const fetchUser = async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
useEffect(() => {
fetchUser();
}, [userId]);
if (state.loading) return <div>Loading...</div>;
if (state.error) return <div>Error: {state.error}</div>;
if (!state.data) return <div>No data</div>;
return <div>{state.data.name}</div>;
}
⚠️ Important Note
Reducers must be synchronous! Async logic goes in the component (or custom hook), which then dispatches actions to the reducer. The reducer only handles state updates.
Pattern 7: Lazy Initialization
Use lazy initialization for expensive initial state calculations:
// Init function - only runs once
function init(initialCount: number): CounterState {
// Expensive calculation (e.g., reading from localStorage)
const savedCount = localStorage.getItem('count');
return {
count: savedCount ? parseInt(savedCount) : initialCount,
history: []
};
}
// Use lazy initialization
const [state, dispatch] = useReducer(reducer, 10, init);
// init(10) is called once on mount
Pattern 8: Middleware Pattern
Add logging or analytics by wrapping dispatch:
function useReducerWithMiddleware<S, A>(
reducer: (state: S, action: A) => S,
initialState: S
) {
const [state, dispatch] = useReducer(reducer, initialState);
// Enhanced dispatch with logging
const enhancedDispatch = (action: A) => {
console.log('Previous State:', state);
console.log('Action:', action);
dispatch(action);
// State updates after this
console.log('New State:', state);
};
return [state, enhancedDispatch] as const;
}
// Use it
const [state, dispatch] = useReducerWithMiddleware(todoReducer, initialState);
⭐ Best Practices
Follow these best practices to write clean, maintainable useReducer code that scales well.
Do's ✅
✅ Keep Reducers Pure
Reducers must be pure functions with no side effects:
- Same inputs always produce same outputs
- No API calls, no timers, no random numbers
- No mutations - always return new state
// ✅ Pure - always returns same result
function reducer(state: number, action: Action) {
return state + 1;
}
// ❌ Impure - side effects
function reducer(state: number, action: Action) {
console.log(state); // Side effect!
fetch('/api'); // Side effect!
return state + Math.random(); // Not deterministic!
}
✅ Use TypeScript
Type everything for safety and great developer experience:
- Define state interface
- Use discriminated unions for actions
- Type the reducer function
- Use exhaustive checks in default case
✅ Use Action Creators
Always create helper functions for actions:
- Ensures consistency
- Provides type safety
- Makes refactoring easier
- Documents what data is needed
✅ Handle All Cases
Always include a default case that returns current state:
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'ACTION_1':
return newState;
case 'ACTION_2':
return newState;
default:
// Always return state for unknown actions
return state;
}
}
✅ Keep State Minimal
Only store what you can't calculate:
- Don't store derived values
- Don't duplicate data
- Calculate computed values during render
✅ Name Actions Clearly
Use descriptive, action-oriented names:
- Use SCREAMING_SNAKE_CASE
- Start with verb (ADD_TODO, not TODO_ADDED)
- Be specific (UPDATE_USER_EMAIL vs UPDATE)
- Group related actions (TODO_ADD, TODO_DELETE)
Don'ts ❌
❌ Don't Mutate State
Never modify state directly - always return a new object:
// ❌ Mutation - breaks React!
function reducer(state: State, action: Action): State {
state.count++; // Don't do this!
return state;
}
// ✅ Create new state
function reducer(state: State, action: Action): State {
return { ...state, count: state.count + 1 };
}
❌ Don't Put Too Much in One Reducer
Split large reducers into smaller ones:
- Keep reducers focused on one domain
- Split when reducer exceeds ~100 lines
- Use reducer composition pattern
❌ Don't Overthink It
Start with useState, refactor to useReducer when:
- State updates become complex
- Multiple related state values
- State logic scattered across component
- Bugs from stale closures
❌ Don't Use for Everything
useState is fine for:
- Simple, independent state
- Single values
- State that doesn't interact with other state
- Form inputs without complex validation
Testing Reducers
Reducers are easy to test because they're pure functions:
// counterReducer.test.ts
import { counterReducer } from './counterReducer';
describe('counterReducer', () => {
it('should increment count', () => {
const state = { count: 0, step: 1 };
const action = { type: 'INCREMENT' as const };
const newState = counterReducer(state, action);
expect(newState.count).toBe(1);
});
it('should not mutate original state', () => {
const state = { count: 0, step: 1 };
const action = { type: 'INCREMENT' as const };
counterReducer(state, action);
expect(state.count).toBe(0); // Original unchanged
});
it('should handle unknown actions', () => {
const state = { count: 5, step: 1 };
const action = { type: 'UNKNOWN' as any };
const newState = counterReducer(state, action);
expect(newState).toBe(state); // Same reference
});
});
💡 Testing Benefits
- No need to render components
- No need to mock APIs
- Fast, focused tests
- Easy to test edge cases
- High confidence in state logic
Performance Considerations
When useReducer Helps Performance
- Callback Stability - dispatch never changes (safe in deps)
- Deep Updates - Update nested state without re-creating callbacks
- Multiple Updates - Batch related state changes
Performance Tips
- Use React.memo for expensive child components
- Pass dispatch instead of callbacks to children
- Split large state into multiple reducers
- Consider Context + useReducer for global state
📚 Summary
Congratulations! You've mastered the useReducer Hook. Let's recap what you've learned.
🎯 Key Takeaways
- useReducer is for complex state - When useState gets messy, reach for useReducer
- Reducers are pure functions - Take (state, action), return new state
- Actions describe what happened - Use action creators for consistency
- TypeScript makes it better - Type safety prevents bugs
- Separation of concerns - Reducer handles logic, component handles UI
- Easy to test - Pure functions are simple to unit test
- Scales well - Add features without increasing complexity
What You Learned
| Concept | What You Can Do |
|---|---|
| Reducer Pattern | Understand how reducers work and why they're useful |
| useReducer Syntax | Use useReducer correctly with proper TypeScript types |
| Actions | Create action types and action creator functions |
| Complex State | Manage multi-piece state with a single reducer |
| Patterns | Apply common patterns for better code |
| Best Practices | Write maintainable, testable reducer code |
When to Use Each Hook
Next Steps
🚀 Continue Learning
Now that you know useReducer, you're ready for:
- Lesson 5.2: useContext - Share state across components
- Context + useReducer - Global state management pattern
- useRef - Mutable values and DOM access
- Performance Hooks - useMemo and useCallback
Practice Projects
✅ Solidify Your Knowledge
Build these projects to master useReducer:
- Kanban Board - Drag and drop tasks between columns
- Expense Tracker - Add, edit, delete expenses with categories
- Quiz App - Multiple questions, score tracking, timer
- Chat Application - Messages, users, typing indicators
- E-commerce Cart - Products, quantities, coupons, checkout
Common Questions
Q: Should I always use useReducer instead of useState?
A: No! useState is perfect for simple state. Use useReducer when state logic becomes complex, you have multiple related state values, or updates depend on previous state in non-trivial ways.
Q: Can I have multiple useReducer calls in one component?
A: Yes! You can use multiple useReducer hooks for different domains of state. For example, one for form data and another for UI state.
Q: How do I handle async operations with useReducer?
A: Async logic goes outside the reducer (in the component or custom hook). Dispatch actions before and after the async operation (FETCH_START, FETCH_SUCCESS, FETCH_ERROR).
Q: Is useReducer better for performance?
A: Not necessarily. The main benefit is the stable dispatch function, which is safe to pass to children and include in dependency arrays. For actual performance optimization, use React.memo, useMemo, and useCallback.
Q: Should I use Redux or useReducer?
A: For component-level or small app state, useReducer is great. For large apps with complex global state, time-travel debugging needs, or extensive middleware requirements, Redux might be better. Many apps don't need Redux anymore thanks to useReducer + Context!
🎉 Congratulations! 🎉
You've completed the useReducer lesson and gained a powerful tool for managing complex state. You can now build scalable React applications with confidence!
Keep practicing, keep building, and keep learning! 🚀