React useReducer Hook
1. useReducer Syntax and Dispatch Patterns (React.useReducer)
| Component | Syntax | Description | Usage |
|---|---|---|---|
| useReducer Hook | useReducer(reducer, initialState) |
Returns [state, dispatch] array |
Alternative to useState for complex state logic |
| With Init Function | useReducer(reducer, initialArg, init) |
Third argument initializes state lazily | Lazy initialization: init(initialArg) called once |
| Dispatch Function | dispatch(action) |
Triggers state update via reducer function | Stable reference - safe for useEffect dependencies |
| Dispatch Pattern | Syntax | Action Structure | Use Case |
|---|---|---|---|
| Type Only | dispatch({ type: 'INCREMENT' }) |
Action with type, no payload | Simple state transitions without data |
| Type + Payload | dispatch({ type: 'SET_VALUE', payload: value }) |
Action with type and data | Most common - pass data to reducer |
| Spread Payload | dispatch({ type: 'UPDATE', ...data }) |
Flatten payload into action object | Multiple values: { type, id, name, age } |
| FSA (Flux Standard Action) | dispatch({ type, payload, error, meta }) |
Standardized action shape | Industry standard for Redux-style actions |
Example: Basic useReducer setup and dispatch
import { useReducer } from 'react';
// Reducer function
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'SET':
return { count: action.payload };
case 'RESET':
return { count: 0 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={() => dispatch({ type: 'SET', payload: 10 })}>Set 10</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
Example: Lazy initialization with init function
// Init function for lazy initialization
function init(initialCount) {
return { count: initialCount, history: [] };
}
function CounterWithHistory({ initialCount }) {
// Third argument: init function called once with initialCount
const [state, dispatch] = useReducer(reducer, initialCount, init);
// state = { count: initialCount, history: [] }
}
2. Reducer Function Patterns and Action Types
| Reducer Pattern | Implementation | Pros | Cons |
|---|---|---|---|
| Switch Statement | switch(action.type) { case 'TYPE': return newState; } |
Clear, explicit, easy to read | Verbose for many action types |
| Object Lookup | const handlers = {TYPE: (state) => newState}; return handlers[action.type]?.(state) |
Concise, functional style | Less explicit error handling |
| If-Else Chain | if (type === 'A') return ...; else if (type === 'B') return ...; |
Simple for few actions | Poor scalability, hard to maintain |
| Action Type Convention | Format | Example | Use Case |
|---|---|---|---|
| UPPER_SNAKE_CASE | 'ACTION_NAME' |
'FETCH_USER', 'UPDATE_PROFILE' |
Traditional Redux style, clear constants |
| Namespaced | 'domain/ACTION' |
'user/FETCH', 'cart/ADD_ITEM' |
Organize by feature, prevent collisions |
| Enum/Constants | const ACTIONS = {ADD: 'ADD'} |
ACTIONS.ADD, ACTIONS.REMOVE |
Type safety, autocomplete, refactoring |
Example: Reducer patterns comparison
// Pattern 1: Switch statement (most common)
function reducer(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) };
case 'CLEAR':
return { ...state, items: [] };
default:
return state; // Or throw error for unknown actions
}
}
// Pattern 2: Object lookup (functional style)
const handlers = {
ADD_ITEM: (state, action) => ({
...state,
items: [...state.items, action.payload]
}),
REMOVE_ITEM: (state, action) => ({
...state,
items: state.items.filter(i => i.id !== action.payload)
}),
CLEAR: (state) => ({ ...state, items: [] })
};
function reducer(state, action) {
const handler = handlers[action.type];
return handler ? handler(state, action) : state;
}
// Pattern 3: Action type constants (type safety)
const ACTIONS = {
ADD_ITEM: 'ADD_ITEM',
REMOVE_ITEM: 'REMOVE_ITEM',
CLEAR: 'CLEAR'
} as const;
function reducer(state, action) {
switch (action.type) {
case ACTIONS.ADD_ITEM:
return { ...state, items: [...state.items, action.payload] };
case ACTIONS.REMOVE_ITEM:
return { ...state, items: state.items.filter(i => i.id !== action.payload) };
case ACTIONS.CLEAR:
return { ...state, items: [] };
default:
return state;
}
}
Best Practice: Always return a new state object. Never mutate the existing state. Use spread
operators or Immer for immutable updates.
3. Action Creators and Payload Structures
| Pattern | Implementation | Benefit |
|---|---|---|
| Inline Action | dispatch({ type: 'ADD', payload: item }) |
Simple, direct - good for one-off actions |
| Action Creator RECOMMENDED | const addItem = (item) => ({ type: 'ADD', payload: item }) |
Reusable, testable, consistent structure |
| Typed Action Creator | const addItem = (item: Item): Action => ({ type: 'ADD', payload: item }) |
Type safety with TypeScript |
| Payload Structure | Example | Use Case |
|---|---|---|
| Single Value | { type: 'SET_NAME', payload: 'John' } |
Simple value updates |
| Object Payload | { type: 'UPDATE_USER', payload: { name, age, email } } |
Multiple related values |
| Indexed Payload | { type: 'UPDATE_ITEM', payload: { id: 1, changes: {...} } } |
Update specific item in collection |
| Normalized Payload | { type: 'ADD_MANY', payload: { byId: {...}, allIds: [...] } } |
Normalized data structures |
Example: Action creators for cleaner code
// Action type constants
const ACTIONS = {
ADD_TODO: 'ADD_TODO',
TOGGLE_TODO: 'TOGGLE_TODO',
DELETE_TODO: 'DELETE_TODO',
SET_FILTER: 'SET_FILTER'
};
// Action creators
const addTodo = (text) => ({
type: ACTIONS.ADD_TODO,
payload: { id: Date.now(), text, completed: false }
});
const toggleTodo = (id) => ({
type: ACTIONS.TOGGLE_TODO,
payload: id
});
const deleteTodo = (id) => ({
type: ACTIONS.DELETE_TODO,
payload: id
});
const setFilter = (filter) => ({
type: ACTIONS.SET_FILTER,
payload: filter
});
// Usage in component
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const handleAdd = (text) => {
dispatch(addTodo(text)); // Clean, readable
};
const handleToggle = (id) => {
dispatch(toggleTodo(id)); // Clear intent
};
return (
<div>
<button onClick={() => handleAdd('New task')}>Add</button>
<button onClick={() => handleToggle(1)}>Toggle</button>
</div>
);
}
Example: TypeScript action creators with discriminated unions
// Define action types with discriminated union
type Action =
| { type: 'ADD_TODO'; payload: { text: string } }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'DELETE_TODO'; payload: number }
| { type: 'SET_FILTER'; payload: 'all' | 'active' | 'completed' };
// Type-safe action creators
const actions = {
addTodo: (text: string): Action => ({
type: 'ADD_TODO',
payload: { text }
}),
toggleTodo: (id: number): Action => ({
type: 'TOGGLE_TODO',
payload: id
}),
deleteTodo: (id: number): Action => ({
type: 'DELETE_TODO',
payload: id
}),
setFilter: (filter: 'all' | 'active' | 'completed'): Action => ({
type: 'SET_FILTER',
payload: filter
})
};
// Reducer with full type inference
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_TODO':
// TypeScript knows action.payload is { text: string }
return { ...state, todos: [...state.todos, { id: Date.now(), ...action.payload, completed: false }] };
case 'TOGGLE_TODO':
// TypeScript knows action.payload is number
return { ...state, todos: state.todos.map(t => t.id === action.payload ? { ...t, completed: !t.completed } : t) };
default:
return state;
}
}
4. State Machine Patterns with useReducer
| State Machine Concept | Description | Benefit |
|---|---|---|
| Finite States | Limited set of possible states (idle, loading, success, error) | Prevents impossible states, clearer logic |
| Transitions | Explicit rules for moving between states | Predictable state changes, easier debugging |
| Guards | Conditions that must be met for transitions | Validation logic, prevent invalid transitions |
| State Machine Pattern | State Structure | Use Case |
|---|---|---|
| Status Enum | { status: 'idle' | 'loading' | 'success' | 'error', data?, error? } |
Async operations, API calls |
| Tagged Union | { type: 'loading' } | { type: 'success', data } | { type: 'error', error } |
Type-safe states with TypeScript |
| Nested States | { phase: 'editing', editMode: { step: 1 | 2 | 3 } } |
Multi-step processes, wizards |
Example: State machine for async data fetching
// State type with discriminated union
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User[] }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'RESET' };
function reducer(state: State, action: Action): State {
switch (state.status) {
case 'idle':
if (action.type === 'FETCH_START') {
return { status: 'loading' };
}
break;
case 'loading':
if (action.type === 'FETCH_SUCCESS') {
return { status: 'success', data: action.payload };
}
if (action.type === 'FETCH_ERROR') {
return { status: 'error', error: action.payload };
}
break;
case 'success':
case 'error':
if (action.type === 'FETCH_START') {
return { status: 'loading' };
}
if (action.type === 'RESET') {
return { status: 'idle' };
}
break;
}
return state; // No valid transition
}
function UserList() {
const [state, dispatch] = useReducer(reducer, { status: 'idle' });
const fetchUsers = async () => {
dispatch({ type: 'FETCH_START' });
try {
const data = await fetch('/api/users').then(r => r.json());
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
if (state.status === 'loading') return <div>Loading...</div>;
if (state.status === 'error') return <div>Error: {state.error}</div>;
if (state.status === 'success') return <div>{state.data.length} users</div>;
return <button onClick={fetchUsers}>Load Users</button>;
}
Impossible States: State machines prevent scenarios like
{ loading: true, error: 'Failed', data: [...] } which are logically impossible but possible with
boolean flags.
5. useReducer vs useState - When to Use Which
| Criterion | Use useState | Use useReducer |
|---|---|---|
| State Complexity | Simple primitives or shallow objects | Complex nested objects, multiple related values |
| Update Logic | Simple assignments: setState(value) |
Complex logic, conditionals, multiple steps |
| Number of Updates | Few state update locations | Many different update operations |
| Predictability | Simple, local state changes | Need centralized, traceable state transitions |
| Testing | Simple component tests sufficient | Want to test reducer logic in isolation |
| Future Changes | State unlikely to grow in complexity | Anticipate adding more actions/transitions |
| Scenario | Best Choice | Reason |
|---|---|---|
| Toggle boolean flag | ✅ useState | Simple on/off state, no complex logic |
| Form with validation | ✅ useReducer | Multiple fields, validation rules, error states |
| Shopping cart | ✅ useReducer | Add/remove/update/clear operations, quantities |
| Modal open/close | ✅ useState | Binary state, simple toggle |
| Multi-step wizard | ✅ useReducer | State machine with steps, navigation, data |
| Counter | ✅ useState | Single value, simple increment/decrement |
| Async data fetching | ✅ useReducer | Loading/success/error states, state machine |
Example: When to migrate from useState to useReducer
// ❌ useState becomes unwieldy with complex state
function ComplexForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
// Hard to coordinate updates across multiple setState calls
const handleSubmit = async () => {
setIsSubmitting(true);
setSubmitError(null);
// ... complex logic
};
}
// ✅ useReducer centralizes complex state management
function ComplexForm() {
const [state, dispatch] = useReducer(formReducer, {
values: { name: '', email: '', age: 0 },
errors: {},
touched: {},
isSubmitting: false,
submitError: null
});
// Single dispatch for coordinated updates
const handleSubmit = async () => {
dispatch({ type: 'SUBMIT_START' });
try {
await submitForm(state.values);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (error) {
dispatch({ type: 'SUBMIT_ERROR', payload: error.message });
}
};
}
6. Async Actions and useReducer Integration
| Pattern | Implementation | Use Case |
|---|---|---|
| Dispatch in Async Handler | Call dispatch before/after async operations in event handlers | Simple async flows in components |
| Thunk Pattern | Function that receives dispatch and executes async logic | Reusable async operations, middleware-like |
| useEffect Integration | useEffect with dispatch for side effects | Fetch on mount, sync with external systems |
| Custom Hook | Wrap useReducer + async logic in custom hook | Encapsulate complex async state management |
| Async State Pattern | State Structure | Actions |
|---|---|---|
| Loading/Error/Data | { loading: boolean, error: Error?, data: T? } |
START, SUCCESS, ERROR |
| Status Enum | { status: 'idle' | 'loading' | 'success' | 'error', ... } |
FETCH_START, FETCH_SUCCESS, FETCH_ERROR, RESET |
| Request Tracking | { requestId: string, pending: boolean, ... } |
Track request IDs to prevent race conditions |
Example: Async actions with useReducer
type State = {
data: User[] | null;
loading: boolean;
error: string | null;
};
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User[] }
| { type: 'FETCH_ERROR'; payload: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START':
return { data: null, loading: true, error: null };
case 'FETCH_SUCCESS':
return { data: action.payload, loading: false, error: null };
case 'FETCH_ERROR':
return { data: null, loading: false, error: action.payload };
default:
return state;
}
}
function UserList() {
const [state, dispatch] = useReducer(reducer, {
data: null,
loading: false,
error: null
});
// Async handler with dispatch
const fetchUsers = async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch('/api/users');
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
// Fetch on mount
useEffect(() => {
fetchUsers();
}, []);
if (state.loading) return <div>Loading...</div>;
if (state.error) return <div>Error: {state.error}</div>;
if (state.data) return <ul>{state.data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
return null;
}
Example: Custom async hook with useReducer
// Reusable async hook
function useAsync<T>(asyncFunction: () => Promise<T>) {
const [state, dispatch] = useReducer(
(state: AsyncState<T>, action: AsyncAction<T>) => {
switch (action.type) {
case 'LOADING': return { status: 'loading' };
case 'SUCCESS': return { status: 'success', data: action.payload };
case 'ERROR': return { status: 'error', error: action.payload };
default: return state;
}
},
{ status: 'idle' }
);
const execute = useCallback(async () => {
dispatch({ type: 'LOADING' });
try {
const data = await asyncFunction();
dispatch({ type: 'SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'ERROR', payload: error.message });
}
}, [asyncFunction]);
return { ...state, execute };
}
// Usage
function UserProfile({ userId }) {
const { status, data, error, execute } = useAsync(() =>
fetch(`/api/users/${userId}`).then(r => r.json())
);
useEffect(() => {
execute();
}, [userId, execute]);
if (status === 'loading') return <div>Loading...</div>;
if (status === 'error') return <div>Error: {error}</div>;
if (status === 'success') return <div>{data.name}</div>;
return null;
}
Race Conditions: Multiple async operations can complete out of order. Use request IDs or abort
controllers to cancel stale requests. React 18's
useTransition can also help manage async state
updates.
useReducer Best Practices Summary
- Use switch statements for clear, explicit action handling
- Create action creators for consistent action structure
- Implement state machines for complex async flows
- Choose useReducer when state has 3+ related pieces
- Dispatch actions in async handlers for coordinated updates
- Extract async logic into custom hooks for reusability
- Use TypeScript discriminated unions for type-safe reducers