useReducer follows a similar concept to Redux, but it manages state locally within a component.
Instead of updating state directly, you dispatch an action and let a reducer determine the next state.
const [state, dispatch] = useReducer(reducer, initialState)
state: The current state.dispatch: Dispatches an action to request a state transition.reducer: A pure function that calculates the next state.Example: Counter
type Action =| { type: 'increment' }| { type: 'decrement' }| { type: 'reset' }function counterReducer(state: number, action: Action): number {switch (action.type) {case 'increment':return state + 1case 'decrement':return state - 1case 'reset':return 0}}function Counter() {const [count, dispatch] = React.useReducer(counterReducer, 0)return (<><p>Count: {count}</p><button onClick={() => dispatch({ type: 'increment' })}>+</button><button onClick={() => dispatch({ type: 'decrement' })}>-</button><button onClick={() => dispatch({ type: 'reset' })}>Reset</button></>)}
The reducer must be pure: it should calculate the next state without mutating the current state or performing side effects.
useReducer?Multiple related state fields
Consider a data-fetching component:
const [loading, setLoading] = useState(false)const [data, setData] = useState<User[] | null>(null)const [error, setError] = useState<string | null>(null)
These fields represent related states, but independent updates can accidentally create inconsistent combinations.
For example, loading might be true while an old error remains visible.
A reducer centralizes the transitions.
Model state transitions explicitly
type State =| { status: 'idle' }| { status: 'loading' }| { status: 'success'; data: User[] }| { status: 'error'; message: string }type Action =| { type: 'fetch' }| { type: 'success'; data: User[] }| { type: 'error'; message: string }function reducer(state: State, action: Action): State {switch (action.type) {case 'fetch':return { status: 'loading' }case 'success':return { status: 'success', data: action.data }case 'error':return { status: 'error', message: action.message }}}
This uses a discriminated union to make invalid state combinations harder to represent.
For example, a success state must contain data, while an error state must contain a message.
Reducers must remain pure. API requests belong in event handlers, effects, or a dedicated data-fetching layer.
async function loadUsers() {dispatch({ type: 'fetch' })try {const response = await fetch('/api/users')if (!response.ok) {throw new Error('Failed to fetch users')}const users: User[] = await response.json()dispatch({ type: 'success', data: users })} catch (error) {dispatch({type: 'error',message:error instanceof Error ? error.message : 'Unknown error',})}}
The reducer handles state transitions; the asynchronous function handles the side effect.