Lesson 10 of 12

useReducer & State Patterns

You will learn how to manage related pieces of state with useReducer, which keeps every update in one . This stops separate useState calls from getting out of sync.

JSX
import { useReducer } from 'react'

// The reducer is a pure function: (state, action) => new state.
// No fetching, no timers, no mutation — just the next state.
const initialState = { items: [], filter: 'all', draft: '' }

function todosReducer(state, action) {
  switch (action.type) {
    case 'draft_changed':
      return { ...state, draft: action.value }

    case 'added':
      if (!state.draft.trim()) return state
      return {
        ...state,
        draft: '',
        items: [...state.items, { id: crypto.randomUUID(), title: state.draft, done: false }],
      }

    case 'toggled':
      return {
        ...state,
        items: state.items.map(t => (t.id === action.id ? { ...t, done: !t.done } : t)),
      }

    case 'deleted':
      return { ...state, items: state.items.filter(t => t.id !== action.id) }

    case 'filter_changed':
      return { ...state, filter: action.filter }

    default:
      throw new Error('Unknown action: ' + action.type)
  }
}

function Todos() {
  const [state, dispatch] = useReducer(todosReducer, initialState)

  const visible = state.items.filter(t =>
    state.filter === 'all' ? true : state.filter === 'done' ? t.done : !t.done
  )

  return (
    <>
      <form onSubmit={e => { e.preventDefault(); dispatch({ type: 'added' }) }}>
        <input
          value={state.draft}
          onChange={e => dispatch({ type: 'draft_changed', value: e.target.value })}
        />
        <button>Add</button>
      </form>

      <ul>
        {visible.map(todo => (
          <li key={todo.id}>
            <input type="checkbox" checked={todo.done}
                   onChange={() => dispatch({ type: 'toggled', id: todo.id })} />
            {todo.title}
            <button onClick={() => dispatch({ type: 'deleted', id: todo.id })}>×</button>
          </li>
        ))}
      </ul>
    </>
  )
}
JSX
import { useReducer, useEffect } from 'react'

// Model state as a state MACHINE so impossible combinations cannot exist.

// Fragile: isLoading && error && data can all be true at once.
// const [data, setData] = useState(null)
// const [isLoading, setIsLoading] = useState(false)
// const [error, setError] = useState(null)

// Solid: exactly one status at a time.
const initial = { status: 'idle', data: null, error: null }

function fetchReducer(state, action) {
  switch (action.type) {
    case 'fetch_started':   return { status: 'loading', data: null, error: null }
    case 'fetch_succeeded': return { status: 'success', data: action.data, error: null }
    case 'fetch_failed':    return { status: 'error',   data: null, error: action.error }
    case 'reset':           return initial
    default:                return state
  }
}

function useFetchReducer(url) {
  const [state, dispatch] = useReducer(fetchReducer, initial)

  useEffect(() => {
    const controller = new AbortController()
    dispatch({ type: 'fetch_started' })

    fetch(url, { signal: controller.signal })
      .then(r => (r.ok ? r.json() : Promise.reject(new Error(r.status))))
      .then(data => dispatch({ type: 'fetch_succeeded', data }))
      .catch(error => {
        if (error.name !== 'AbortError') dispatch({ type: 'fetch_failed', error })
      })

    return () => controller.abort()
  }, [url])

  return state
}
JSX
import { createContext, useContext, useReducer } from 'react'

// Reducer + Context = shared state without prop drilling and without a library.
const CartContext = createContext(null)
const CartDispatchContext = createContext(null)

function cartReducer(state, action) {
  switch (action.type) {
    case 'added': {
      const existing = state.items.find(i => i.id === action.item.id)
      return existing
        ? { ...state, items: state.items.map(i =>
              i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i) }
        : { ...state, items: [...state.items, { ...action.item, qty: 1 }] }
    }
    case 'removed':
      return { ...state, items: state.items.filter(i => i.id !== action.id) }
    case 'cleared':
      return { items: [] }
    default:
      return state
  }
}

export function CartProvider({ children }) {
  const [cart, dispatch] = useReducer(cartReducer, { items: [] })
  // Two contexts: components that only dispatch do not re-render when the cart changes.
  return (
    <CartContext.Provider value={cart}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartContext.Provider>
  )
}

export const useCart = () => useContext(CartContext)
export const useCartDispatch = () => useContext(CartDispatchContext)

function AddToCartButton({ product }) {
  const dispatch = useCartDispatch()
  return <button onClick={() => dispatch({ type: 'added', item: product })}>Add</button>
}
Finished reading?Mark it done to track your progress.

Tip: use the ← and → keys to move between lessons.