Back to Blog
ReactHooksuseReducerState Management

React useReducer Guide

Master React useReducer hook for complex state management in functional components.

B
Bootspring Team
Engineering
September 29, 2018
7 min read

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/debugging

Best 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.

Share this article

Help spread the word about Bootspring