useReducer
In this chapter, you learn about the useReducer hook, used to manage complex state logic.
What you'll learn in this chapter
- The basic syntax and usage of useReducer
- Choosing between it and useState
- Reducer design principles (pure functions, immutable updates)
- Combining with useContext
This chapter uses the project created in Chapter 3. Start the development server with npm run dev and study while writing code.
What is useReducer
useReducer is a hook that serves as an alternative to useState, and it's suited to complex state logic spanning multiple values, or to cases where the next state depends on the previous state.
For those who know Redux: useReducer brings Redux's reducer pattern into React. For small to medium applications, useReducer + useContext is often enough without introducing Redux.
Basic syntax
const [state, dispatch] = useReducer(reducer, initialState)
| Element | Description |
|---|---|
state | The current state |
dispatch | The function that dispatches an action |
reducer | The function that holds the logic to update the state |
initialState | The initial state |
Basic usage
A counter example
import { useReducer } from 'react'
// The state type
type CounterState = {
count: number
}
// The action type (define all actions with a union type)
// Each action requires a type property
// payload is only when additional data is needed
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'set'; payload: number } // pass a value with payload
// The initial state
const initialState: CounterState = { count: 0 }
// The reducer function: (the current state, an action) => the new state
// Implement it as a pure function (no side effects, doesn't mutate the state directly)
function counterReducer(state: CounterState, action: CounterAction): CounterState {
// Update the state according to action.type
switch (action.type) {
case 'increment':
return { count: state.count + 1 } // return a new object
case 'decrement':
return { count: state.count - 1 }
case 'reset':
return initialState // return to the initial state
case 'set':
return { count: action.payload } // use the payload value
default:
return state // an unknown action doesn't change the state
}
}
// Component
function Counter() {
// useReducer: returns [the current state, the dispatch function]
// Dispatch an action to update the state
const [state, dispatch] = useReducer(counterReducer, initialState)
// dispatch({ type: 'action name' }) triggers a state update
const handleIncrement = () => dispatch({ type: 'increment' })
const handleDecrement = () => dispatch({ type: 'decrement' })
const handleReset = () => dispatch({ type: 'reset' })
const handleSetHundred = () => dispatch({ type: 'set', payload: 100 })
return (
<div>
<p>Count: {state.count}</p>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleDecrement}>-1</button>
<button onClick={handleReset}>Reset</button>
<button onClick={handleSetHundred}>Set to 100</button>
</div>
)
}
Let's try it: improve the counter
Implement a counter with useReducer that has increment, decrement, reset, and set-to-an-arbitrary-value actions.
Hint
- Define four actions with the
CounterActionunion type - Handle each action with a
switchstatement - Pass a value with the
payloadproperty for thesetaction - Handle the actions safely with TypeScript's types
Answer and explanation
import { useReducer } from 'react'
import './App.css'
type CounterState = {
count: number
}
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'set'; payload: number }
const initialState: CounterState = { count: 0 }
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { count: state.count + 1 }
case 'decrement':
return { count: state.count - 1 }
case 'reset':
return initialState
case 'set':
return { count: action.payload }
default:
return state
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, initialState)
const handleIncrement = () => dispatch({ type: 'increment' })
const handleDecrement = () => dispatch({ type: 'decrement' })
const handleReset = () => dispatch({ type: 'reset' })
const handleSetHundred = () => dispatch({ type: 'set', payload: 100 })
return (
<div>
<p>Count: {state.count}</p>
<button onClick={handleIncrement}>+1</button>
<button onClick={handleDecrement}>-1</button>
<button onClick={handleReset}>Reset</button>
<button onClick={handleSetHundred}>Set to 100</button>
</div>
)
}
export default function App() {
return <Counter />
}
With useReducer, the state update logic is consolidated into the reducer function, and the intent of each action becomes clear. Using TypeScript's union types, specifying a non-existent action type becomes a compile error.
Comparing useReducer and useState
When to use useReducer
| Situation | useState | useReducer |
|---|---|---|
| Simple state (one value) | ○ | △ |
| Multiple related states | △ | ○ |
| Complex state update logic | △ | ○ |
| Updates that depend on the previous state | △ | ○ |
| Testing state updates | Hard | Easy |
Comparing the same feature
// With useState: suited to simple state
function CounterWithState() {
const [count, setCount] = useState(0)
// The update logic is scattered inside the component
const increment = () => setCount(prev => prev + 1)
const decrement = () => setCount(prev => prev - 1)
const reset = () => setCount(0)
return (/* ... */)
}
// With useReducer: suited to complex state logic
function CounterWithReducer() {
const [state, dispatch] = useReducer(counterReducer, { count: 0 })
// The action name makes "what it does" clear
const handleIncrement = () => dispatch({ type: 'increment' })
return (
<>
<button onClick={handleIncrement}>+1</button>
{/* The update logic is consolidated in the reducer function */}
</>
)
}
Practical examples
Managing form state
type FormState = {
name: string
email: string
password: string
confirmPassword: string
errors: Record<string, string>
isSubmitting: boolean
}
type FormAction =
| { type: 'SET_FIELD'; field: keyof Omit<FormState, 'errors' | 'isSubmitting'>; value: string }
| { type: 'SET_ERROR'; field: string; message: string }
| { type: 'CLEAR_ERRORS' }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_END' }
| { type: 'RESET' }
const initialFormState: FormState = {
name: '',
email: '',
password: '',
confirmPassword: '',
errors: {},
isSubmitting: false
}
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return {
...state, // copy the existing state (spread syntax)
[action.field]: action.value, // update a specific field with a dynamic property name
errors: { ...state.errors, [action.field]: '' } // clear the error on field change
}
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.message }
}
case 'CLEAR_ERRORS':
return { ...state, errors: {} } // set errors to an empty object
case 'SUBMIT_START':
return { ...state, isSubmitting: true }
case 'SUBMIT_END':
return { ...state, isSubmitting: false }
case 'RESET':
return initialFormState // return to the initial state
default:
return state
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState)
const handleChange = (field: keyof Omit<FormState, 'errors' | 'isSubmitting'>) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'SET_FIELD', field, value: e.target.value })
}
const validate = (): boolean => {
let isValid = true
if (!state.name.trim()) {
dispatch({ type: 'SET_ERROR', field: 'name', message: 'Name is required' })
isValid = false
}
if (!state.email.includes('@')) {
dispatch({ type: 'SET_ERROR', field: 'email', message: 'Please enter a valid email address' })
isValid = false
}
if (state.password.length < 8) {
dispatch({ type: 'SET_ERROR', field: 'password', message: 'Password must be at least 8 characters' })
isValid = false
}
if (state.password !== state.confirmPassword) {
dispatch({ type: 'SET_ERROR', field: 'confirmPassword', message: 'Passwords do not match' })
isValid = false
}
return isValid
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
dispatch({ type: 'CLEAR_ERRORS' })
if (!validate()) return
dispatch({ type: 'SUBMIT_START' })
try {
await registerUser(state)
dispatch({ type: 'RESET' })
} finally {
dispatch({ type: 'SUBMIT_END' })
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<input
value={state.name}
onChange={handleChange('name')}
placeholder="Name"
/>
{state.errors.name && <span className="error">{state.errors.name}</span>}
</div>
<div>
<input
type="email"
value={state.email}
onChange={handleChange('email')}
placeholder="Email"
/>
{state.errors.email && <span className="error">{state.errors.email}</span>}
</div>
<div>
<input
type="password"
value={state.password}
onChange={handleChange('password')}
placeholder="Password"
/>
{state.errors.password && <span className="error">{state.errors.password}</span>}
</div>
<div>
<input
type="password"
value={state.confirmPassword}
onChange={handleChange('confirmPassword')}
placeholder="Confirm password"
/>
{state.errors.confirmPassword && <span className="error">{state.errors.confirmPassword}</span>}
</div>
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? 'Submitting...' : 'Register'}
</button>
</form>
)
}
Let's try it: managing form state
Implement a form with useReducer that manages multiple fields (name, email), validation errors, and the submission state.
Hint
- Include field values, errors (a Record type), and isSubmitting in the
FormStatetype - Clear the error on field update with
SET_FIELD - Implement the
SET_ERROR,CLEAR_ERRORS,SUBMIT_START,SUBMIT_END, andRESETactions - Set multiple errors in the validation function
Answer and explanation
import { useReducer } from 'react'
import './App.css'
type FormState = {
name: string
email: string
errors: Record<string, string>
isSubmitting: boolean
}
type FormAction =
| { type: 'SET_FIELD'; field: 'name' | 'email'; value: string }
| { type: 'SET_ERROR'; field: string; message: string }
| { type: 'CLEAR_ERRORS' }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_END' }
| { type: 'RESET' }
const initialFormState: FormState = {
name: '',
email: '',
errors: {},
isSubmitting: false
}
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
[action.field]: action.value,
errors: { ...state.errors, [action.field]: '' }
}
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.message }
}
case 'CLEAR_ERRORS':
return { ...state, errors: {} }
case 'SUBMIT_START':
return { ...state, isSubmitting: true }
case 'SUBMIT_END':
return { ...state, isSubmitting: false }
case 'RESET':
return initialFormState
default:
return state
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialFormState)
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'SET_FIELD', field: 'name', value: e.target.value })
}
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: 'SET_FIELD', field: 'email', value: e.target.value })
}
const validate = (): boolean => {
let isValid = true
if (!state.name.trim()) {
dispatch({ type: 'SET_ERROR', field: 'name', message: 'Name is required' })
isValid = false
}
if (!state.email.includes('@')) {
dispatch({ type: 'SET_ERROR', field: 'email', message: 'Please enter a valid email address' })
isValid = false
}
return isValid
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
dispatch({ type: 'CLEAR_ERRORS' })
if (!validate()) return
dispatch({ type: 'SUBMIT_START' })
// Simulate an API call
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('Submit:', { name: state.name, email: state.email })
dispatch({ type: 'SUBMIT_END' })
dispatch({ type: 'RESET' })
}
return (
<form onSubmit={handleSubmit}>
<div>
<input
value={state.name}
onChange={handleNameChange}
placeholder="Name"
/>
{state.errors.name && <span style={{ color: 'red' }}>{state.errors.name}</span>}
</div>
<div>
<input
type="email"
value={state.email}
onChange={handleEmailChange}
placeholder="Email"
/>
{state.errors.email && <span style={{ color: 'red' }}>{state.errors.email}</span>}
</div>
<button type="submit" disabled={state.isSubmitting}>
{state.isSubmitting ? 'Submitting...' : 'Register'}
</button>
</form>
)
}
export default function App() {
return <RegistrationForm />
}
Managing a form's complex state (field values, errors, submission state) with a single reducer makes the flow of state easier to follow. You can write related processing together, such as automatically clearing the error on a field change.
Managing data fetching state
type FetchState<T> = {
data: T | null
loading: boolean
error: string | null
}
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'RESET' }
function createFetchReducer<T>() {
return function fetchReducer(
state: FetchState<T>,
action: FetchAction<T>
): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null }
case 'FETCH_SUCCESS':
return { data: action.payload, loading: false, error: null }
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload }
case 'RESET':
return { data: null, loading: false, error: null }
default:
return state
}
}
}
// Extracted as a custom hook
function useFetch<T>(fetchFn: () => Promise<T>) {
const reducer = createFetchReducer<T>()
const [state, dispatch] = useReducer(reducer, {
data: null,
loading: false,
error: null
})
const execute = useCallback(async () => {
dispatch({ type: 'FETCH_START' })
try {
const data = await fetchFn()
dispatch({ type: 'FETCH_SUCCESS', payload: data })
} catch (e) {
dispatch({ type: 'FETCH_ERROR', payload: e instanceof Error ? e.message : 'Unknown error' })
}
}, [fetchFn])
const reset = useCallback(() => {
dispatch({ type: 'RESET' })
}, [])
return { ...state, execute, reset }
}
// Usage
function UserList() {
const { data: users, loading, error, execute } = useFetch(fetchUsers)
useEffect(() => {
execute()
}, [execute])
if (loading) return <p>Loading...</p>
if (error) return <p>Error: {error}</p>
if (!users) return null
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
Managing a shopping cart
type CartItem = {
id: string
name: string
price: number
quantity: number
}
type CartState = {
items: CartItem[]
totalItems: number
totalPrice: number
}
type CartAction =
| { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> }
| { type: 'REMOVE_ITEM'; payload: string }
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' }
const initialCartState: CartState = {
items: [],
totalItems: 0,
totalPrice: 0
}
function calculateTotals(items: CartItem[]): Pick<CartState, 'totalItems' | 'totalPrice'> {
return {
totalItems: items.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
}
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM': {
// Search whether the same product is already in the cart
const existingIndex = state.items.findIndex(item => item.id === action.payload.id)
let newItems: CartItem[]
if (existingIndex >= 0) {
// Existing item: update only the matching item's quantity with map
newItems = state.items.map((item, index) =>
index === existingIndex
? { ...item, quantity: item.quantity + 1 } // quantity +1
: item // leave other items as-is
)
} else {
// New item: add to the array with spread
newItems = [...state.items, { ...action.payload, quantity: 1 }]
}
// Recalculate the totals and return
return { items: newItems, ...calculateTotals(newItems) }
}
case 'REMOVE_ITEM': {
// Exclude the matching item with filter
const newItems = state.items.filter(item => item.id !== action.payload)
return { items: newItems, ...calculateTotals(newItems) }
}
case 'UPDATE_QUANTITY': {
const { id, quantity } = action.payload
// If the quantity is 0 or less, delete it
if (quantity <= 0) {
const newItems = state.items.filter(item => item.id !== id)
return { items: newItems, ...calculateTotals(newItems) }
}
// Update the matching item's quantity with map
const newItems = state.items.map(item =>
item.id === id ? { ...item, quantity } : item
)
return { items: newItems, ...calculateTotals(newItems) }
}
case 'CLEAR_CART':
return initialCartState // return to the initial state (an empty cart)
default:
return state
}
}
function ShoppingCart() {
const [cart, dispatch] = useReducer(cartReducer, initialCartState)
const createDecrementHandler = (id: string, quantity: number) => () => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity: quantity - 1 } })
}
const createIncrementHandler = (id: string, quantity: number) => () => {
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity: quantity + 1 } })
}
const createRemoveHandler = (id: string) => () => {
dispatch({ type: 'REMOVE_ITEM', payload: id })
}
const handleClearCart = () => {
dispatch({ type: 'CLEAR_CART' })
}
return (
<div>
<h2>Cart ({cart.totalItems} items)</h2>
{cart.items.map(item => (
<div key={item.id} className="cart-item">
<span>{item.name}</span>
<span>¥{item.price}</span>
<div className="quantity-controls">
<button onClick={createDecrementHandler(item.id, item.quantity)}>
-
</button>
<span>{item.quantity}</span>
<button onClick={createIncrementHandler(item.id, item.quantity)}>
+
</button>
</div>
<button onClick={createRemoveHandler(item.id)}>
Delete
</button>
</div>
))}
<div className="cart-summary">
<p>Total: ¥{cart.totalPrice.toLocaleString()}</p>
<button onClick={handleClearCart}>
Clear cart
</button>
</div>
</div>
)
}
Let's try it: a Todo list
Implement a Todo list with useReducer that has ADD, REMOVE, and TOGGLE actions.
Hint
- Define a
Todotype (id, text, completed) - Add a new item with
ADD(generate the id withDate.now()) - Delete with
REMOVEusing filter - Flip
completedwithTOGGLEusing map
Answer and explanation
import { useReducer, useState } from 'react'
import './App.css'
type Todo = {
id: number
text: string
completed: boolean
}
type TodoState = {
todos: Todo[]
}
type TodoAction =
| { type: 'ADD'; payload: string }
| { type: 'REMOVE'; payload: number }
| { type: 'TOGGLE'; payload: number }
const initialTodoState: TodoState = { todos: [] }
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case 'ADD':
return {
todos: [
...state.todos,
{ id: Date.now(), text: action.payload, completed: false }
]
}
case 'REMOVE':
return {
todos: state.todos.filter(todo => todo.id !== action.payload)
}
case 'TOGGLE':
return {
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
}
default:
return state
}
}
function TodoList() {
const [state, dispatch] = useReducer(todoReducer, initialTodoState)
const [input, setInput] = useState('')
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value)
}
const handleAdd = () => {
if (input.trim()) {
dispatch({ type: 'ADD', payload: input })
setInput('')
}
}
const createToggleHandler = (id: number) => () => {
dispatch({ type: 'TOGGLE', payload: id })
}
const createRemoveHandler = (id: number) => () => {
dispatch({ type: 'REMOVE', payload: id })
}
return (
<div>
<div>
<input
value={input}
onChange={handleInputChange}
placeholder="New task"
/>
<button onClick={handleAdd}>Add</button>
</div>
<ul>
{state.todos.map(todo => (
<li key={todo.id}>
<span
onClick={createToggleHandler(todo.id)}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
cursor: 'pointer'
}}
>
{todo.text}
</span>
<button onClick={createRemoveHandler(todo.id)}>
Delete
</button>
</li>
))}
</ul>
</div>
)
}
export default function App() {
return <TodoList />
}
By managing the state update logic with a reducer, the type makes it explicit what kinds of actions are possible. Testing is also easy, since you can unit test the reducer function alone.
Reducer design principles
1. It must be a pure function
A reducer must be a pure function that always returns the same output for the same input.
// NG: contains a side effect (something you must not do inside a reducer)
function badReducer(state, action) {
switch (action.type) {
case 'ADD':
localStorage.setItem('count', state.count + 1) // NG: side effect (writing to the outside)
return { count: state.count + 1 }
}
}
// OK: a pure function (the same input always gives the same output)
function goodReducer(state, action) {
switch (action.type) {
case 'ADD':
return { count: state.count + 1 } // just return the new state
}
}
// Do side effects (such as saving to localStorage) in useEffect
2. Don't mutate the state directly
// NG: mutating the state directly
function badReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
state.items.push(action.payload) // NG: mutating the original array directly
return state // returning the same reference doesn't trigger a re-render
}
}
// OK: return a new object (immutable update)
function goodReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM':
// Create a new object/array with spread syntax
return { ...state, items: [...state.items, action.payload] }
}
}
3. Make action types clear
// NG: a vague action type
{ type: 'UPDATE' }
// OK: action types whose intent is clear
{ type: 'UPDATE_USER_NAME' }
{ type: 'INCREMENT_QUANTITY' }
{ type: 'TOGGLE_VISIBILITY' }
Testing a reducer
Since a reducer is a pure function, it's easy to test. The following test code assumes you extracted the cartReducer and initialCartState from the shopping cart example into cartReducer.ts and added an export to each.
// cartReducer.test.ts
import { describe, it, expect } from 'vitest'
import { cartReducer, initialCartState } from './cartReducer'
describe('cartReducer', () => {
it('should add item to empty cart', () => {
const action = {
type: 'ADD_ITEM' as const,
payload: { id: '1', name: 'Apple', price: 100 }
}
const result = cartReducer(initialCartState, action)
expect(result.items).toHaveLength(1)
expect(result.items[0].quantity).toBe(1)
expect(result.totalPrice).toBe(100)
})
it('should increase quantity when adding existing item', () => {
const stateWithItem = {
items: [{ id: '1', name: 'Apple', price: 100, quantity: 1 }],
totalItems: 1,
totalPrice: 100
}
const action = {
type: 'ADD_ITEM' as const,
payload: { id: '1', name: 'Apple', price: 100 }
}
const result = cartReducer(stateWithItem, action)
expect(result.items).toHaveLength(1)
expect(result.items[0].quantity).toBe(2)
expect(result.totalPrice).toBe(200)
})
})
Summary
useReduceris a hook for managing complex state logic- Consolidate the state update logic into a reducer function
- Update the state by dispatching an action with
dispatch - It's a pure function and doesn't mutate the state directly
- You can handle actions safely with TypeScript type definitions
- It's easy to test
- It can also be combined with
useContextfor global state management
In the next chapter, you learn about custom hooks.