useReducer - Complex State Management
What is useReducer?
useReducer is an alternative to useState for managing complex state logic. It's similar to Redux and follows the same reducer pattern.
When to Use useReducer vs useState
Use useState when:
- Simple state (string, number, boolean)
- Independent state variables
- State updates are straightforward
Use useReducer when:
- Complex state object with multiple sub-values
- Next state depends on previous state
- State transitions follow specific logic
- Multiple actions can update the same state
- Want to separate state logic from components
Basic Usage
import { useReducer } from 'react';
type State = { count: number };
type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' };
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 'reset':
return { count: 0 };
default:
throw new Error(`Unknown action type`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { 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: 'reset' })}>Reset</button>
</div>
);
}
Actions with Payload
type State = { count: number };
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'set'; payload: number }
| { type: 'add'; 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 };
case 'add':
return { count: state.count + action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'add', payload: 5 })}>+5</button>
<button onClick={() => dispatch({ type: 'set', payload: 100 })}>
Set to 100
</button>
</div>
);
}
Complex State Example: Todo List
interface Todo {
id: string;
text: string;
completed: boolean;
}
type State = {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
};
type Action =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: string }
| { type: 'DELETE_TODO'; payload: string }
| { type: 'EDIT_TODO'; payload: { id: string; text: string } }
| { type: 'SET_FILTER'; payload: 'all' | 'active' | 'completed' }
| { type: 'CLEAR_COMPLETED' };
function todoReducer(state: State, action: Action): State {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{
id: crypto.randomUUID(),
text: action.payload,
completed: false,
},
],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
),
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload),
};
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,
};
case 'CLEAR_COMPLETED':
return {
...state,
todos: state.todos.filter(todo => !todo.completed),
};
default:
return state;
}
}
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, {
todos: [],
filter: 'all',
});
const addTodo = (text: string) => {
dispatch({ type: 'ADD_TODO', payload: text });
};
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed;
if (state.filter === 'completed') return todo.completed;
return true;
});
return (
<div>
<TodoInput onAdd={addTodo} />
<FilterButtons
currentFilter={state.filter}
onFilterChange={filter =>
dispatch({ type: 'SET_FILTER', payload: filter })
}
/>
<TodoList
todos={filteredTodos}
onToggle={id => dispatch({ type: 'TOGGLE_TODO', payload: id })}
onDelete={id => dispatch({ type: 'DELETE_TODO', payload: id })}
onEdit={(id, text) =>
dispatch({ type: 'EDIT_TODO', payload: { id, text } })
}
/>
<button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>
Clear Completed
</button>
</div>
);
}
Form State Management
interface FormState {
values: {
username: string;
email: string;
password: string;
};
errors: {
username?: string;
email?: string;
password?: string;
};
touched: {
username: boolean;
email: boolean;
password: boolean;
};
isSubmitting: boolean;
}
type FormAction =
| { type: 'SET_FIELD'; payload: { field: string; value: string } }
| { type: 'SET_ERROR'; payload: { field: string; error: string } }
| { type: 'SET_TOUCHED'; payload: string }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_ERROR' }
| { type: 'RESET' };
const initialFormState: FormState = {
values: { username: '', email: '', password: '' },
errors: {},
touched: { username: false, email: false, password: false },
isSubmitting: false,
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
values: {
...state.values,
[action.payload.field]: action.payload.value,
},
};
case 'SET_ERROR':
return {
...state,
errors: {
...state.errors,
[action.payload.field]: action.payload.error,
},
};
case 'SET_TOUCHED':
return {
...state,
touched: {
...state.touched,
[action.payload]: true,
},
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_SUCCESS':
return initialFormState;
case 'SUBMIT_ERROR':
return { ...state, isSubmitting: false };
case 'RESET':
return initialFormState;
default:
return state;
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState);
const handleChange = (field: string, value: string) => {
dispatch({ type: 'SET_FIELD', payload: { field, value } });
// Validate
const error = validate(field, value);
if (error) {
dispatch({ type: 'SET_ERROR', payload: { field, error } });
}
};
const handleBlur = (field: string) => {
dispatch({ type: 'SET_TOUCHED', payload: field });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
try {
await submitForm(state.values);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (error) {
dispatch({ type: 'SUBMIT_ERROR' });
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.username}
onChange={e => handleChange('username', e.target.value)}
onBlur={() => handleBlur('username')}
/>
{state.touched.username && state.errors.username && (
<span>{state.errors.username}</span>
)}
{/* More fields... */}
<button type='submit' disabled={state.isSubmitting}>
{state.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
Async Operations with useReducer
type AsyncState\<T\> = {
data: T | null;
loading: boolean;
error: string | null;
};
type AsyncAction\<T\> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'RESET' };
function asyncReducer\<T\>(
state: AsyncState\<T\>,
action: AsyncAction\<T\>
): AsyncState\<T\> {
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 };
case 'RESET':
return { data: null, loading: false, error: null };
default:
return state;
}
}
function UserProfile({ userId }: { userId: string }) {
const [state, dispatch] = useReducer(asyncReducer<User>, {
data: null,
loading: false,
error: null,
});
useEffect(() => {
let cancelled = false;
async function fetchUser() {
dispatch({ type: 'FETCH_START' });
try {
const user = await fetchUserAPI(userId);
if (!cancelled) {
dispatch({ type: 'FETCH_SUCCESS', payload: user });
}
} catch (error) {
if (!cancelled) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
}
}
fetchUser();
return () => {
cancelled = true;
};
}, [userId]);
if (state.loading) return <Spinner />;
if (state.error) return <Error message={state.error} />;
if (!state.data) return null;
return <UserCard user={state.data} />;
}
useReducer with useContext
// Create combined context for state and dispatch
type CartState = {
items: CartItem[];
total: number;
};
type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' };
const CartContext = createContext<
| {
state: CartState;
dispatch: React.Dispatch<CartAction>;
}
| undefined
>(undefined);
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.payload.id);
const items = existing
? state.items.map(i =>
i.id === action.payload.id ? { ...i, quantity: i.quantity + 1 } : i
)
: [...state.items, { ...action.payload, quantity: 1 }];
return {
items,
total: items.reduce((sum, item) => sum + item.price * item.quantity, 0),
};
}
case 'REMOVE_ITEM': {
const items = state.items.filter(i => i.id !== action.payload);
return {
items,
total: items.reduce((sum, item) => sum + item.price * item.quantity, 0),
};
}
case 'UPDATE_QUANTITY': {
const items = state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: action.payload.quantity }
: i
);
return {
items,
total: items.reduce((sum, item) => sum + item.price * item.quantity, 0),
};
}
case 'CLEAR_CART':
return { items: [], total: 0 };
default:
return state;
}
}
function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
// Custom hook with helper functions
function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error('useCart must be used within CartProvider');
const { state, dispatch } = context;
return {
items: state.items,
total: state.total,
addItem: (item: CartItem) => dispatch({ type: 'ADD_ITEM', payload: item }),
removeItem: (id: string) => dispatch({ type: 'REMOVE_ITEM', payload: id }),
updateQuantity: (id: string, quantity: number) =>
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }),
clearCart: () => dispatch({ type: 'CLEAR_CART' }),
};
}
Lazy Initialization
function init(initialCount: number) {
// Expensive initialization
return { count: initialCount };
}
function Counter({ initialCount }: { initialCount: number }) {
// Third argument is init function
const [state, dispatch] = useReducer(reducer, initialCount, init);
return <div>Count: {state.count}</div>;
}
Immer for Immutable Updates
import { useImmerReducer } from 'use-immer';
type State = {
users: User[];
selectedUserId: string | null;
};
type Action =
| { type: 'ADD_USER'; payload: User }
| { type: 'UPDATE_USER'; payload: { id: string; updates: Partial<User> } }
| { type: 'DELETE_USER'; payload: string }
| { type: 'SELECT_USER'; payload: string };
function userReducer(draft: State, action: Action) {
switch (action.type) {
case 'ADD_USER':
draft.users.push(action.payload);
break;
case 'UPDATE_USER': {
const user = draft.users.find(u => u.id === action.payload.id);
if (user) {
Object.assign(user, action.payload.updates);
}
break;
}
case 'DELETE_USER':
draft.users = draft.users.filter(u => u.id !== action.payload);
break;
case 'SELECT_USER':
draft.selectedUserId = action.payload;
break;
}
}
function UserManagement() {
const [state, dispatch] = useImmerReducer(userReducer, {
users: [],
selectedUserId: null,
});
// Now you can mutate draft directly in reducer!
}
Best Practices
- Use discriminated unions - TypeScript will ensure exhaustive checking
- Keep reducers pure - No side effects, API calls, or mutations
- One reducer per domain - Don't mix unrelated state
- Use action creators - For complex actions
- Combine with Context - For global state
- Consider Immer - For complex nested state
- Type actions properly - Use discriminated unions
Common Patterns
Action Creators
const actions = {
addTodo: (text: string): Action => ({
type: 'ADD_TODO',
payload: text,
}),
toggleTodo: (id: string): Action => ({
type: 'TOGGLE_TODO',
payload: id,
}),
deleteTodo: (id: string): Action => ({
type: 'DELETE_TODO',
payload: id,
}),
};
// Usage
dispatch(actions.addTodo('Learn React'));
Reducer Composition
function combineReducers<S>(reducers: {
[K in keyof S]: (state: S[K], action: any) => S[K];
}) {
return (state: S, action: any): S => {
return Object.keys(reducers).reduce((nextState, key) => {
nextState[key as keyof S] = reducers[key as keyof S](
state[key as keyof S],
action
);
return nextState;
}, {} as S);
};
}
const rootReducer = combineReducers({
todos: todoReducer,
user: userReducer,
ui: uiReducer,
});
Practice Exercises
- Build a shopping cart with useReducer
- Create a multi-step form with state managed by reducer
- Implement undo/redo functionality
- Build a kanban board with drag-and-drop
- Create a quiz app with score tracking
Next Steps
- Learn about useRef
- Explore Performance Hooks