useReducer & State Machines
MeduseReducer manages complex state logic by dispatching actions to a reducer function. It is preferred over useState when state transitions depend on previous state, when multiple sub-values are related, or when you want to centralize state logic for testing and reasoning.
Interactive Visualization
Key Points
- Takes a reducer function and initial state, returns [state, dispatch]
- Reducer is a pure function: (state, action) => newState
- Actions describe what happened, the reducer decides how state changes
- TypeScript discriminated unions make actions type-safe
- Reducers are easy to test in isolation since they are pure functions
- useReducer can be combined with Context to create a mini state management system
Code Examples
Typed Reducer with Discriminated Unions
import { useReducer } from 'react' interface TodoState { todos: { id: number; text: string; done: boolean }[] nextId: number } type TodoAction = | { type: 'ADD'; text: string } | { type: 'TOGGLE'; id: number } | { type: 'DELETE'; id: number } function todoReducer(state: TodoState, action: TodoAction): TodoState { switch (action.type) { case 'ADD': return { todos: [...state.todos, { id: state.nextId, text: action.text, done: false }], nextId: state.nextId + 1, } case 'TOGGLE': return { ...state, todos: state.todos.map((t) => t.id === action.id ? { ...t, done: !t.done } : t ), } case 'DELETE': return { ...state, todos: state.todos.filter((t) => t.id !== action.id), } } } function TodoApp() { const [state, dispatch] = useReducer(todoReducer, { todos: [], nextId: 1, }) return ( <div> <button onClick={() => dispatch({ type: 'ADD', text: 'New todo' })}> Add </button> {state.todos.map((todo) => ( <div key={todo.id}> <span onClick={() => dispatch({ type: 'TOGGLE', id: todo.id })} style={{ textDecoration: todo.done ? 'line-through' : 'none' }} > {todo.text} </span> </div> ))} </div> ) }
Discriminated union actions ensure TypeScript narrows the payload type in each case, making invalid actions impossible to dispatch
Async State Machine
import { useReducer } from 'react' type AsyncState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: string } type AsyncAction<T> = | { type: 'FETCH' } | { type: 'RESOLVE'; data: T } | { type: 'REJECT'; error: string } | { type: 'RESET' } function asyncReducer<T>( state: AsyncState<T>, action: AsyncAction<T> ): AsyncState<T> { switch (action.type) { case 'FETCH': return { status: 'loading' } case 'RESOLVE': return { status: 'success', data: action.data } case 'REJECT': return { status: 'error', error: action.error } case 'RESET': return { status: 'idle' } } } function useAsync<T>() { return useReducer(asyncReducer<T>, { status: 'idle' } as AsyncState<T>) }
Discriminated union state types make invalid states unrepresentable, guaranteeing data exists only when status is success
Common Mistakes
- Mutating the state object inside the reducer instead of returning a new one
- Using useReducer for simple toggle or counter state where useState is cleaner
- Putting async logic inside the reducer which must be a pure function
Interview Tips
- Compare useState vs useReducer: useReducer shines when state transitions are complex or interdependent
- Explain how discriminated unions prevent invalid state combinations at the type level
- Discuss the pattern of useReducer + Context as a lightweight Redux alternative