Skip to main content

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

  1. Use discriminated unions - TypeScript will ensure exhaustive checking
  2. Keep reducers pure - No side effects, API calls, or mutations
  3. One reducer per domain - Don't mix unrelated state
  4. Use action creators - For complex actions
  5. Combine with Context - For global state
  6. Consider Immer - For complex nested state
  7. 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

  1. Build a shopping cart with useReducer
  2. Create a multi-step form with state managed by reducer
  3. Implement undo/redo functionality
  4. Build a kanban board with drag-and-drop
  5. Create a quiz app with score tracking

Next Steps