useReducer Hook for Complex State Logic
1. useReducer Hook Syntax and Dispatch Patterns
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 Decision Matrix
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