useReducer is a powerful alternative to useState for complex state logic. Here's how to use it effectively.
Basic useReducer#
1import { useReducer } from 'react';
2
3// Reducer function
4function counterReducer(state, action) {
5 switch (action.type) {
6 case 'increment':
7 return { count: state.count + 1 };
8 case 'decrement':
9 return { count: state.count - 1 };
10 case 'reset':
11 return { count: 0 };
12 default:
13 throw new Error(`Unknown action: ${action.type}`);
14 }
15}
16
17function Counter() {
18 const [state, dispatch] = useReducer(counterReducer, { count: 0 });
19
20 return (
21 <div>
22 <p>Count: {state.count}</p>
23 <button onClick={() => dispatch({ type: 'increment' })}>+</button>
24 <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
25 <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
26 </div>
27 );
28}Action Payloads#
1function todoReducer(state, action) {
2 switch (action.type) {
3 case 'add':
4 return {
5 ...state,
6 todos: [...state.todos, {
7 id: Date.now(),
8 text: action.payload,
9 completed: false
10 }]
11 };
12
13 case 'toggle':
14 return {
15 ...state,
16 todos: state.todos.map(todo =>
17 todo.id === action.payload
18 ? { ...todo, completed: !todo.completed }
19 : todo
20 )
21 };
22
23 case 'delete':
24 return {
25 ...state,
26 todos: state.todos.filter(todo => todo.id !== action.payload)
27 };
28
29 case 'edit':
30 return {
31 ...state,
32 todos: state.todos.map(todo =>
33 todo.id === action.payload.id
34 ? { ...todo, text: action.payload.text }
35 : todo
36 )
37 };
38
39 default:
40 return state;
41 }
42}
43
44// Usage
45dispatch({ type: 'add', payload: 'New todo' });
46dispatch({ type: 'toggle', payload: todoId });
47dispatch({ type: 'edit', payload: { id: todoId, text: 'Updated' } });Lazy Initialization#
1// For expensive initial state computation
2function init(initialCount) {
3 // Could read from localStorage, compute, etc.
4 return { count: initialCount };
5}
6
7function Counter({ initialCount }) {
8 const [state, dispatch] = useReducer(
9 counterReducer,
10 initialCount,
11 init // Third argument - initializer function
12 );
13
14 // init(initialCount) called only on mount
15
16 return <div>{state.count}</div>;
17}
18
19// Practical example
20function TodoList({ initialTodos }) {
21 const [state, dispatch] = useReducer(
22 todoReducer,
23 initialTodos,
24 (todos) => ({
25 todos,
26 filter: 'all',
27 loading: false
28 })
29 );
30}Complex State#
1const initialState = {
2 items: [],
3 loading: false,
4 error: null,
5 page: 1,
6 hasMore: true
7};
8
9function dataReducer(state, action) {
10 switch (action.type) {
11 case 'FETCH_START':
12 return {
13 ...state,
14 loading: true,
15 error: null
16 };
17
18 case 'FETCH_SUCCESS':
19 return {
20 ...state,
21 loading: false,
22 items: [...state.items, ...action.payload.items],
23 hasMore: action.payload.hasMore,
24 page: state.page + 1
25 };
26
27 case 'FETCH_ERROR':
28 return {
29 ...state,
30 loading: false,
31 error: action.payload
32 };
33
34 case 'RESET':
35 return initialState;
36
37 default:
38 return state;
39 }
40}
41
42function DataList() {
43 const [state, dispatch] = useReducer(dataReducer, initialState);
44
45 const loadMore = async () => {
46 dispatch({ type: 'FETCH_START' });
47
48 try {
49 const data = await fetchData(state.page);
50 dispatch({ type: 'FETCH_SUCCESS', payload: data });
51 } catch (error) {
52 dispatch({ type: 'FETCH_ERROR', payload: error.message });
53 }
54 };
55
56 return (
57 <div>
58 {state.items.map(item => <Item key={item.id} {...item} />)}
59 {state.loading && <Spinner />}
60 {state.error && <Error message={state.error} />}
61 {state.hasMore && !state.loading && (
62 <button onClick={loadMore}>Load More</button>
63 )}
64 </div>
65 );
66}Form State Management#
1const initialFormState = {
2 values: { name: '', email: '', message: '' },
3 errors: {},
4 touched: {},
5 isSubmitting: false
6};
7
8function formReducer(state, action) {
9 switch (action.type) {
10 case 'SET_FIELD':
11 return {
12 ...state,
13 values: {
14 ...state.values,
15 [action.field]: action.value
16 }
17 };
18
19 case 'SET_ERROR':
20 return {
21 ...state,
22 errors: {
23 ...state.errors,
24 [action.field]: action.error
25 }
26 };
27
28 case 'SET_TOUCHED':
29 return {
30 ...state,
31 touched: {
32 ...state.touched,
33 [action.field]: true
34 }
35 };
36
37 case 'SUBMIT_START':
38 return { ...state, isSubmitting: true };
39
40 case 'SUBMIT_SUCCESS':
41 return initialFormState;
42
43 case 'SUBMIT_ERROR':
44 return {
45 ...state,
46 isSubmitting: false,
47 errors: action.errors
48 };
49
50 case 'RESET':
51 return initialFormState;
52
53 default:
54 return state;
55 }
56}
57
58function ContactForm() {
59 const [state, dispatch] = useReducer(formReducer, initialFormState);
60
61 const handleChange = (e) => {
62 dispatch({
63 type: 'SET_FIELD',
64 field: e.target.name,
65 value: e.target.value
66 });
67 };
68
69 const handleBlur = (e) => {
70 dispatch({ type: 'SET_TOUCHED', field: e.target.name });
71 // Validate field
72 };
73
74 return (
75 <form>
76 <input
77 name="name"
78 value={state.values.name}
79 onChange={handleChange}
80 onBlur={handleBlur}
81 />
82 {state.touched.name && state.errors.name && (
83 <span>{state.errors.name}</span>
84 )}
85 </form>
86 );
87}Action Creators#
1// Action type constants
2const ACTIONS = {
3 ADD_TODO: 'ADD_TODO',
4 TOGGLE_TODO: 'TOGGLE_TODO',
5 DELETE_TODO: 'DELETE_TODO'
6};
7
8// Action creators
9const addTodo = (text) => ({
10 type: ACTIONS.ADD_TODO,
11 payload: { id: Date.now(), text, completed: false }
12});
13
14const toggleTodo = (id) => ({
15 type: ACTIONS.TOGGLE_TODO,
16 payload: id
17});
18
19const deleteTodo = (id) => ({
20 type: ACTIONS.DELETE_TODO,
21 payload: id
22});
23
24// Usage
25function TodoApp() {
26 const [state, dispatch] = useReducer(todoReducer, { todos: [] });
27
28 const handleAdd = (text) => {
29 dispatch(addTodo(text));
30 };
31
32 const handleToggle = (id) => {
33 dispatch(toggleTodo(id));
34 };
35
36 // ...
37}useReducer with Context#
1import { createContext, useContext, useReducer } from 'react';
2
3const TodoContext = createContext();
4
5function todoReducer(state, action) {
6 // ... reducer logic
7}
8
9function TodoProvider({ children }) {
10 const [state, dispatch] = useReducer(todoReducer, { todos: [] });
11
12 return (
13 <TodoContext.Provider value={{ state, dispatch }}>
14 {children}
15 </TodoContext.Provider>
16 );
17}
18
19function useTodos() {
20 const context = useContext(TodoContext);
21 if (!context) {
22 throw new Error('useTodos must be used within TodoProvider');
23 }
24 return context;
25}
26
27// Usage in components
28function TodoList() {
29 const { state } = useTodos();
30 return state.todos.map(todo => <Todo key={todo.id} {...todo} />);
31}
32
33function AddTodo() {
34 const { dispatch } = useTodos();
35
36 const handleSubmit = (text) => {
37 dispatch({ type: 'add', payload: text });
38 };
39
40 return <TodoForm onSubmit={handleSubmit} />;
41}TypeScript with useReducer#
1// State type
2interface State {
3 count: number;
4 error: string | null;
5}
6
7// Action types
8type Action =
9 | { type: 'increment' }
10 | { type: 'decrement' }
11 | { type: 'set'; payload: number }
12 | { type: 'error'; payload: string };
13
14// Typed reducer
15function reducer(state: State, action: Action): State {
16 switch (action.type) {
17 case 'increment':
18 return { ...state, count: state.count + 1 };
19 case 'decrement':
20 return { ...state, count: state.count - 1 };
21 case 'set':
22 return { ...state, count: action.payload };
23 case 'error':
24 return { ...state, error: action.payload };
25 default:
26 // Exhaustive check
27 const _exhaustive: never = action;
28 return state;
29 }
30}
31
32function Counter() {
33 const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
34
35 // TypeScript knows the valid actions
36 dispatch({ type: 'increment' });
37 dispatch({ type: 'set', payload: 10 });
38}useReducer vs useState#
1// useState - simple state
2function Counter() {
3 const [count, setCount] = useState(0);
4 return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
5}
6
7// useReducer - complex state with multiple sub-values
8function Form() {
9 const [state, dispatch] = useReducer(formReducer, initialState);
10 // Better when state logic is complex
11}
12
13// useReducer - state depends on previous state
14function Game() {
15 const [state, dispatch] = useReducer(gameReducer, initialState);
16 // Actions clearly describe what happened
17}
18
19// Use useReducer when:
20// - State has multiple sub-values
21// - Next state depends on previous state
22// - State logic is complex
23// - You want to test reducer separately
24// - You need action history/debuggingBest Practices#
Reducer Design:
✓ Keep reducers pure
✓ Return new state objects
✓ Handle unknown actions gracefully
✓ Use action type constants
State Structure:
✓ Normalize nested data
✓ Keep state minimal
✓ Derive values when possible
✓ Initialize with all fields
Actions:
✓ Use descriptive action types
✓ Keep payload minimal
✓ Use action creators
✓ Document action shapes
Avoid:
✗ Side effects in reducers
✗ Mutating state directly
✗ Complex logic in components
✗ Over-engineering simple state
Conclusion#
useReducer excels at managing complex state with clear action-based updates. Use it when state has multiple sub-values, when next state depends on previous state, or when you want testable, predictable state logic. Combine with Context for global state management, and use TypeScript for type-safe actions and state.