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.