Back to Blog
ReactHooksuseReducerState Management

React useReducer Patterns Guide

Master React useReducer patterns for complex state management with predictable updates.

B
Bootspring Team
Engineering
September 16, 2019
9 min read

The useReducer hook provides predictable state management for complex state logic. Here are the essential patterns.

Basic Usage#

1import { useReducer } from 'react'; 2 3type State = { 4 count: number; 5}; 6 7type Action = 8 | { type: 'increment' } 9 | { type: 'decrement' } 10 | { type: 'reset' }; 11 12function reducer(state: State, action: Action): State { 13 switch (action.type) { 14 case 'increment': 15 return { count: state.count + 1 }; 16 case 'decrement': 17 return { count: state.count - 1 }; 18 case 'reset': 19 return { count: 0 }; 20 default: 21 return state; 22 } 23} 24 25function Counter() { 26 const [state, dispatch] = useReducer(reducer, { count: 0 }); 27 28 return ( 29 <div> 30 <p>Count: {state.count}</p> 31 <button onClick={() => dispatch({ type: 'increment' })}>+</button> 32 <button onClick={() => dispatch({ type: 'decrement' })}>-</button> 33 <button onClick={() => dispatch({ type: 'reset' })}>Reset</button> 34 </div> 35 ); 36}

Actions with Payloads#

1type State = { 2 items: string[]; 3 loading: boolean; 4 error: string | null; 5}; 6 7type Action = 8 | { type: 'ADD_ITEM'; payload: string } 9 | { type: 'REMOVE_ITEM'; payload: number } 10 | { type: 'SET_ITEMS'; payload: string[] } 11 | { type: 'SET_LOADING'; payload: boolean } 12 | { type: 'SET_ERROR'; payload: string | null }; 13 14function reducer(state: State, action: Action): State { 15 switch (action.type) { 16 case 'ADD_ITEM': 17 return { ...state, items: [...state.items, action.payload] }; 18 case 'REMOVE_ITEM': 19 return { 20 ...state, 21 items: state.items.filter((_, i) => i !== action.payload), 22 }; 23 case 'SET_ITEMS': 24 return { ...state, items: action.payload }; 25 case 'SET_LOADING': 26 return { ...state, loading: action.payload }; 27 case 'SET_ERROR': 28 return { ...state, error: action.payload }; 29 default: 30 return state; 31 } 32} 33 34const initialState: State = { 35 items: [], 36 loading: false, 37 error: null, 38}; 39 40function ItemList() { 41 const [state, dispatch] = useReducer(reducer, initialState); 42 43 const addItem = (item: string) => { 44 dispatch({ type: 'ADD_ITEM', payload: item }); 45 }; 46 47 const removeItem = (index: number) => { 48 dispatch({ type: 'REMOVE_ITEM', payload: index }); 49 }; 50 51 return ( 52 <div> 53 {state.items.map((item, i) => ( 54 <div key={i}> 55 {item} 56 <button onClick={() => removeItem(i)}>Remove</button> 57 </div> 58 ))} 59 </div> 60 ); 61}

Lazy Initialization#

1type State = { 2 todos: Todo[]; 3 filter: 'all' | 'active' | 'completed'; 4}; 5 6function init(initialTodos: Todo[]): State { 7 // Expensive initialization logic 8 return { 9 todos: initialTodos, 10 filter: 'all', 11 }; 12} 13 14function reducer(state: State, action: Action): State { 15 // ... reducer logic 16} 17 18function TodoApp({ initialTodos }: { initialTodos: Todo[] }) { 19 // Third argument is init function, receives second argument 20 const [state, dispatch] = useReducer(reducer, initialTodos, init); 21 22 return <div>{/* ... */}</div>; 23}

Form State Management#

1type FormState = { 2 values: { 3 name: string; 4 email: string; 5 password: string; 6 }; 7 errors: { 8 name?: string; 9 email?: string; 10 password?: string; 11 }; 12 touched: { 13 name: boolean; 14 email: boolean; 15 password: boolean; 16 }; 17 isSubmitting: boolean; 18}; 19 20type FormAction = 21 | { type: 'SET_FIELD'; field: keyof FormState['values']; value: string } 22 | { type: 'SET_ERROR'; field: keyof FormState['errors']; error: string } 23 | { type: 'TOUCH_FIELD'; field: keyof FormState['touched'] } 24 | { type: 'SET_SUBMITTING'; value: boolean } 25 | { type: 'RESET' }; 26 27const initialFormState: FormState = { 28 values: { name: '', email: '', password: '' }, 29 errors: {}, 30 touched: { name: false, email: false, password: false }, 31 isSubmitting: false, 32}; 33 34function formReducer(state: FormState, action: FormAction): FormState { 35 switch (action.type) { 36 case 'SET_FIELD': 37 return { 38 ...state, 39 values: { ...state.values, [action.field]: action.value }, 40 }; 41 case 'SET_ERROR': 42 return { 43 ...state, 44 errors: { ...state.errors, [action.field]: action.error }, 45 }; 46 case 'TOUCH_FIELD': 47 return { 48 ...state, 49 touched: { ...state.touched, [action.field]: true }, 50 }; 51 case 'SET_SUBMITTING': 52 return { ...state, isSubmitting: action.value }; 53 case 'RESET': 54 return initialFormState; 55 default: 56 return state; 57 } 58} 59 60function SignupForm() { 61 const [state, dispatch] = useReducer(formReducer, initialFormState); 62 63 const handleChange = (field: keyof FormState['values']) => ( 64 e: React.ChangeEvent<HTMLInputElement> 65 ) => { 66 dispatch({ type: 'SET_FIELD', field, value: e.target.value }); 67 }; 68 69 const handleBlur = (field: keyof FormState['touched']) => () => { 70 dispatch({ type: 'TOUCH_FIELD', field }); 71 }; 72 73 return ( 74 <form> 75 <input 76 value={state.values.name} 77 onChange={handleChange('name')} 78 onBlur={handleBlur('name')} 79 /> 80 {state.touched.name && state.errors.name && ( 81 <span>{state.errors.name}</span> 82 )} 83 </form> 84 ); 85}

Async Actions Pattern#

1type DataState<T> = { 2 data: T | null; 3 loading: boolean; 4 error: Error | null; 5}; 6 7type DataAction<T> = 8 | { type: 'FETCH_START' } 9 | { type: 'FETCH_SUCCESS'; payload: T } 10 | { type: 'FETCH_ERROR'; payload: Error }; 11 12function createDataReducer<T>() { 13 return function reducer( 14 state: DataState<T>, 15 action: DataAction<T> 16 ): DataState<T> { 17 switch (action.type) { 18 case 'FETCH_START': 19 return { ...state, loading: true, error: null }; 20 case 'FETCH_SUCCESS': 21 return { data: action.payload, loading: false, error: null }; 22 case 'FETCH_ERROR': 23 return { ...state, loading: false, error: action.payload }; 24 default: 25 return state; 26 } 27 }; 28} 29 30function useAsync<T>(asyncFn: () => Promise<T>) { 31 const reducer = createDataReducer<T>(); 32 const [state, dispatch] = useReducer(reducer, { 33 data: null, 34 loading: false, 35 error: null, 36 }); 37 38 const execute = async () => { 39 dispatch({ type: 'FETCH_START' }); 40 try { 41 const data = await asyncFn(); 42 dispatch({ type: 'FETCH_SUCCESS', payload: data }); 43 } catch (error) { 44 dispatch({ type: 'FETCH_ERROR', payload: error as Error }); 45 } 46 }; 47 48 return { ...state, execute }; 49} 50 51// Usage 52function UserProfile({ userId }: { userId: string }) { 53 const { data, loading, error, execute } = useAsync(() => 54 fetch(`/api/users/${userId}`).then((r) => r.json()) 55 ); 56 57 useEffect(() => { 58 execute(); 59 }, [userId]); 60 61 if (loading) return <p>Loading...</p>; 62 if (error) return <p>Error: {error.message}</p>; 63 if (!data) return null; 64 65 return <div>{data.name}</div>; 66}

Immer Integration#

1import { useReducer } from 'react'; 2import { produce } from 'immer'; 3 4type State = { 5 users: Array<{ 6 id: string; 7 name: string; 8 settings: { 9 theme: string; 10 notifications: boolean; 11 }; 12 }>; 13}; 14 15type Action = 16 | { type: 'UPDATE_USER_NAME'; userId: string; name: string } 17 | { type: 'TOGGLE_NOTIFICATIONS'; userId: string } 18 | { type: 'ADD_USER'; user: State['users'][0] }; 19 20const reducer = produce((draft: State, action: Action) => { 21 switch (action.type) { 22 case 'UPDATE_USER_NAME': { 23 const user = draft.users.find((u) => u.id === action.userId); 24 if (user) user.name = action.name; 25 break; 26 } 27 case 'TOGGLE_NOTIFICATIONS': { 28 const user = draft.users.find((u) => u.id === action.userId); 29 if (user) user.settings.notifications = !user.settings.notifications; 30 break; 31 } 32 case 'ADD_USER': 33 draft.users.push(action.user); 34 break; 35 } 36}); 37 38function UserManager() { 39 const [state, dispatch] = useReducer(reducer, { users: [] }); 40 41 // Direct nested updates without spread operators 42 const toggleNotifications = (userId: string) => { 43 dispatch({ type: 'TOGGLE_NOTIFICATIONS', userId }); 44 }; 45 46 return <div>{/* ... */}</div>; 47}

Context + Reducer Pattern#

1import { createContext, useContext, useReducer, ReactNode } from 'react'; 2 3type State = { 4 user: User | null; 5 theme: 'light' | 'dark'; 6}; 7 8type Action = 9 | { type: 'SET_USER'; payload: User | null } 10 | { type: 'SET_THEME'; payload: 'light' | 'dark' }; 11 12const initialState: State = { 13 user: null, 14 theme: 'light', 15}; 16 17function reducer(state: State, action: Action): State { 18 switch (action.type) { 19 case 'SET_USER': 20 return { ...state, user: action.payload }; 21 case 'SET_THEME': 22 return { ...state, theme: action.payload }; 23 default: 24 return state; 25 } 26} 27 28const StateContext = createContext<State | null>(null); 29const DispatchContext = createContext<React.Dispatch<Action> | null>(null); 30 31function AppProvider({ children }: { children: ReactNode }) { 32 const [state, dispatch] = useReducer(reducer, initialState); 33 34 return ( 35 <StateContext.Provider value={state}> 36 <DispatchContext.Provider value={dispatch}> 37 {children} 38 </DispatchContext.Provider> 39 </StateContext.Provider> 40 ); 41} 42 43function useAppState() { 44 const context = useContext(StateContext); 45 if (!context) throw new Error('useAppState must be used within AppProvider'); 46 return context; 47} 48 49function useAppDispatch() { 50 const context = useContext(DispatchContext); 51 if (!context) throw new Error('useAppDispatch must be used within AppProvider'); 52 return context; 53} 54 55// Usage 56function ThemeToggle() { 57 const { theme } = useAppState(); 58 const dispatch = useAppDispatch(); 59 60 return ( 61 <button 62 onClick={() => 63 dispatch({ 64 type: 'SET_THEME', 65 payload: theme === 'light' ? 'dark' : 'light', 66 }) 67 } 68 > 69 Toggle Theme 70 </button> 71 ); 72}

Action Creators#

1// Define action creators for type safety and reusability 2const actions = { 3 increment: () => ({ type: 'INCREMENT' as const }), 4 decrement: () => ({ type: 'DECREMENT' as const }), 5 setValue: (value: number) => ({ type: 'SET_VALUE' as const, payload: value }), 6 reset: () => ({ type: 'RESET' as const }), 7}; 8 9type Action = ReturnType<typeof actions[keyof typeof actions]>; 10 11function reducer(state: number, action: Action): number { 12 switch (action.type) { 13 case 'INCREMENT': 14 return state + 1; 15 case 'DECREMENT': 16 return state - 1; 17 case 'SET_VALUE': 18 return action.payload; 19 case 'RESET': 20 return 0; 21 default: 22 return state; 23 } 24} 25 26function Counter() { 27 const [count, dispatch] = useReducer(reducer, 0); 28 29 return ( 30 <div> 31 <p>{count}</p> 32 <button onClick={() => dispatch(actions.increment())}>+</button> 33 <button onClick={() => dispatch(actions.decrement())}>-</button> 34 <button onClick={() => dispatch(actions.setValue(10))}>Set 10</button> 35 <button onClick={() => dispatch(actions.reset())}>Reset</button> 36 </div> 37 ); 38}

Multiple Reducers#

1// Combine multiple reducers 2function combineReducers<S extends Record<string, any>>( 3 reducers: { [K in keyof S]: (state: S[K], action: any) => S[K] } 4) { 5 return function(state: S, action: any): S { 6 const newState = {} as S; 7 let hasChanged = false; 8 9 for (const key in reducers) { 10 const previousStateForKey = state[key]; 11 const nextStateForKey = reducers[key](previousStateForKey, action); 12 newState[key] = nextStateForKey; 13 hasChanged = hasChanged || nextStateForKey !== previousStateForKey; 14 } 15 16 return hasChanged ? newState : state; 17 }; 18} 19 20// Usage 21const rootReducer = combineReducers({ 22 counter: counterReducer, 23 todos: todosReducer, 24 user: userReducer, 25}); 26 27function App() { 28 const [state, dispatch] = useReducer(rootReducer, { 29 counter: 0, 30 todos: [], 31 user: null, 32 }); 33 34 return <div>{/* ... */}</div>; 35}

Middleware Pattern#

1type Middleware<S, A> = ( 2 dispatch: React.Dispatch<A> 3) => (state: S) => (action: A) => void; 4 5function useReducerWithMiddleware<S, A>( 6 reducer: (state: S, action: A) => S, 7 initialState: S, 8 middlewares: Middleware<S, A>[] 9) { 10 const [state, dispatch] = useReducer(reducer, initialState); 11 12 const enhancedDispatch = (action: A) => { 13 // Apply middlewares 14 middlewares.forEach((middleware) => { 15 middleware(dispatch)(state)(action); 16 }); 17 dispatch(action); 18 }; 19 20 return [state, enhancedDispatch] as const; 21} 22 23// Logger middleware 24const loggerMiddleware: Middleware<any, any> = 25 (dispatch) => (state) => (action) => { 26 console.log('Previous state:', state); 27 console.log('Action:', action); 28 }; 29 30// Usage 31const [state, dispatch] = useReducerWithMiddleware( 32 reducer, 33 initialState, 34 [loggerMiddleware] 35);

Best Practices#

When to Use: ✓ Complex state logic ✓ Multiple sub-values ✓ State depends on previous state ✓ Testable state updates Patterns: ✓ Discriminated unions for actions ✓ Action creators for reusability ✓ Lazy initialization ✓ Context for global state Performance: ✓ Stable dispatch reference ✓ Split contexts (state/dispatch) ✓ Memoize selectors ✓ Use Immer for deep updates Avoid: ✗ Over-engineering simple state ✗ Mutating state directly ✗ Missing default case ✗ Huge monolithic reducers

Conclusion#

The useReducer hook provides predictable state management through explicit actions and pure reducer functions. Use it for complex state logic, form management, or when state updates depend on previous values. Combine with Context for global state management and consider Immer for easier immutable updates. The dispatch function is stable across renders, making it ideal for passing to child components.

Share this article

Help spread the word about Bootspring