ReactuseReducer vs useState

useReducer vs useState

React gives you two primary hooks for managing local component state. Choosing between them is a design decision, not a technical one — both can implement the same feature. The goal is to pick the approach that makes the code easier to read, test, and extend as requirements change.

The Core Difference
  • useState is ideal for a single, independent value — a boolean, a string, a number, or a small object that changes as a whole.

  • useReducer is ideal for structured state with multiple fields that are updated via clearly defined operations.

Same Feature, Two Implementations

Consider a to-do list. Here is the same feature implemented with each hook so you can compare the tradeoffs side by side.

With useState:

JSX
import { useState } from 'react'

function TodoList() {
  const [todos, setTodos] = useState([])
  const [input, setInput] = useState('')

  function addTodo() {
    if (!input.trim()) return
    setTodos([...todos, { id: Date.now(), text: input, done: false }])
    setInput('')
  }

  function toggleTodo(id) {
    setTodos(todos.map(t => (t.id === id ? { ...t, done: !t.done } : t)))
  }

  function removeTodo(id) {
    setTodos(todos.filter(t => t.id !== id))
  }

  return (
    <div>
      <input value={input} onChange={e => setInput(e.target.value)} />
      <button onClick={addTodo}>Add</button>
      {todos.map(todo => (
        <div key={todo.id}>
          <span
            style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
            onClick={() => toggleTodo(todo.id)}
          >
            {todo.text}
          </span>
          <button onClick={() => removeTodo(todo.id)}>Delete</button>
        </div>
      ))}
    </div>
  )
}

With useReducer:

JSX
import { useReducer, useState } from 'react'

const initialState = []

function todosReducer(state, action) {
  switch (action.type) {
    case 'add':
      return [...state, { id: Date.now(), text: action.payload, done: false }]
    case 'toggle':
      return state.map(t =>
        t.id === action.payload ? { ...t, done: !t.done } : t
      )
    case 'remove':
      return state.filter(t => t.id !== action.payload)
    default:
      throw new Error('Unknown action: ' + action.type)
  }
}

function TodoList() {
  const [todos, dispatch] = useReducer(todosReducer, initialState)
  const [input, setInput] = useState('')   // simple input value stays as useState

  function addTodo() {
    if (!input.trim()) return
    dispatch({ type: 'add', payload: input })
    setInput('')
  }

  return (
    <div>
      <input value={input} onChange={e => setInput(e.target.value)} />
      <button onClick={addTodo}>Add</button>
      {todos.map(todo => (
        <div key={todo.id}>
          <span
            style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
            onClick={() => dispatch({ type: 'toggle', payload: todo.id })}
          >
            {todo.text}
          </span>
          <button onClick={() => dispatch({ type: 'remove', payload: todo.id })}>
            Delete
          </button>
        </div>
      ))}
    </div>
  )
}
Decision Framework

Factor

Lean useState

Lean useReducer

State shape

Single value or small flat object

Nested object or multiple related fields

Number of operations

1–2 setters

3+ distinct mutation types

Transition logic

Simple assignment

Next state depends on previous state or action payload in complex ways

Testing

Test via render

Test the pure reducer function directly

Debugging

Add console.log to setter calls

Log every dispatched action; Redux DevTools integration

Team readability

Familiar to everyone

Self-documenting via named action types

The dispatch Stability Benefit

One underappreciated advantage of useReducer is that dispatch has a stable identity — it never changes between renders. Compare this to setter functions from useState, which are also stable, but the functions you create that call them are not:

JSX
// With useState — you create a NEW function on every render
function Parent() {
  const [count, setCount] = useState(0)

  // NEW function reference on every render
  const increment = () => setCount(c => c + 1)

  return <Child onIncrement={increment} />  // triggers re-render of Child every time
}

// With useReducer — dispatch itself is stable; pass it directly
function Parent() {
  const [state, dispatch] = useReducer(reducer, { count: 0 })

  // dispatch never changes — Child only re-renders when state changes
  return <Child dispatch={dispatch} />
}
Note
This stability benefit matters when passing dispatch to deeply nested children or memoized components. Passing dispatch directly (instead of wrapping it in a callback) avoids unneeded re-renders.
Testing Pure Reducers

Because a reducer is a plain function with no side effects, you can test every state transition without mounting a React component. This is one of the biggest practical wins of the reducer pattern:

JS
// todosReducer.test.js — no React, no render, no DOM
import { todosReducer } from './todosReducer'

describe('todosReducer', () => {
  test('add action inserts a todo', () => {
    const result = todosReducer([], { type: 'add', payload: 'Buy milk' })
    expect(result).toHaveLength(1)
    expect(result[0].text).toBe('Buy milk')
    expect(result[0].done).toBe(false)
  })

  test('toggle action flips done flag', () => {
    const initial = [{ id: 1, text: 'Buy milk', done: false }]
    const result = todosReducer(initial, { type: 'toggle', payload: 1 })
    expect(result[0].done).toBe(true)
  })

  test('remove action deletes the correct todo', () => {
    const initial = [
      { id: 1, text: 'Buy milk', done: false },
      { id: 2, text: 'Walk dog', done: false },
    ]
    const result = todosReducer(initial, { type: 'remove', payload: 1 })
    expect(result).toHaveLength(1)
    expect(result[0].id).toBe(2)
  })
})
Combining Both Hooks

You do not have to choose one or the other for the entire component. A common and idiomatic pattern is to use useReducer for the complex domain state and useState for simple UI state:

JSX
function TodoApp() {
  // Complex domain state — reducer
  const [todos, dispatch] = useReducer(todosReducer, [])

  // Simple UI state — useState
  const [inputValue, setInputValue] = useState('')
  const [filterMode, setFilterMode] = useState('all') // 'all' | 'active' | 'done'

  const visible = todos.filter(t => {
    if (filterMode === 'active') return !t.done
    if (filterMode === 'done') return t.done
    return true
  })

  // ...
}
Tip
A good rule of thumb: if writing the setState call inside an event handler requires you to think about the existing state to compute the next state, that complexity belongs in a reducer. If you can write the update as a straightforward replacement, useState is fine.
Context + useReducer: Redux-Lite

Pairing useReducer with useContext gives you a lightweight global state system without any external library — sometimes called "Redux-lite":

JSX
const StoreContext = createContext(null)

function StoreProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, initialState)

  // Pass both state and dispatch through context
  return (
    <StoreContext.Provider value={{ state, dispatch }}>
      {children}
    </StoreContext.Provider>
  )
}

function useStore() {
  const ctx = useContext(StoreContext)
  if (!ctx) throw new Error('useStore must be inside StoreProvider')
  return ctx
}

// Any component can read state and dispatch actions
function ProductCard({ product }) {
  const { dispatch } = useStore()
  return (
    <button
      onClick={() => dispatch({ type: 'add_to_cart', payload: product })}
    >
      Add to cart
    </button>
  )
}