Logo
Published on

Reducers

How Redux Reducers Handle State Updates

Redux Reducers are pure functions that take the current state and an action, then return a new state object. They are the only place where state mutations are allowed in Redux, making state changes predictable and traceable. Teams using well-structured reducers report fewer state-related bugs and easier debugging.

TL;DR

  • Use pure functions that take (state, action) and return new state
  • Reducers must never mutate the existing state object
  • Switch statements handle different action types cleanly
  • Perfect for complex state updates and nested data structures
const result = process(data)

The State Mutation Challenge

Your app's state updates are scattered and unpredictable, with components directly modifying state objects. This breaks Redux's time-travel debugging and causes subtle bugs when state changes don't trigger re-renders. Tracking down where state got corrupted becomes impossible.

// Problematic: Direct state mutation
let userState = { users: [], loading: false, error: null }
function badStateUpdate(action) {
  // Mutating existing state directly - breaks Redux!
  userState.users.push(action.user)
  userState.loading = false
  console.log('Direct mutation:', userState)
  return userState // Returns same reference!
}
const action = { type: 'ADD_USER', user: { id: 1, name: 'John' } }
badStateUpdate(action)

Redux reducers solve this with pure functions that always return new state objects:

// Redux reducer: Pure function with predictable updates
const initialState = { users: [], loading: false }
function userReducer(state = initialState, action) {
  switch (action.type) {
    case 'ADD_USER':
      console.log('Adding user:', action.user.name)
      return { ...state, users: [...state.users, action.user] }
    default:
      return state
  }
}
const action = { type: 'ADD_USER', user: { id: 1, name: 'John' } }
const newState = userReducer(undefined, action)
console.log('Pure state update:', newState)

Best Practises

Use reducers when:

  • ✅ Managing complex state that needs predictable updates
  • ✅ Building applications where state changes must be traceable
  • ✅ Implementing undo/redo functionality or time-travel debugging
  • ✅ Creating normalized state structures with nested relationships

Avoid when:

  • 🚩 Simple component state that doesn't need global access
  • 🚩 Frequently changing values like form inputs or animations
  • 🚩 Temporary UI state that resets on component unmount
  • 🚩 State updates that require complex async side effects

System Design Trade-offs

AspectPure ReducersDirect Mutations
PredictabilityExcellent - same input = same outputPoor - depends on existing state
DebuggingBest - time-travel debugging worksImpossible - state history lost
TestingEasy - simple input/output functionsHard - requires complex mocking
PerformanceGood - structural sharing optimizationsPoor - forces unnecessary re-renders
ConcurrencySafe - no race conditionsDangerous - mutations can conflict
Learning CurveMedium - pure function conceptsLow - straightforward mutations

More Code Examples

❌ Mutating state directly
// Direct state mutation - breaks Redux principles
let appState = {
  users: [{ id: 1, name: 'Alice' }],
  posts: [],
  ui: { loading: false },
}

function badReducer(action) {
  switch (action.type) {
    case 'ADD_USER':
      // Mutating existing array - breaks Redux!
      appState.users.push(action.user)
      console.log('Mutated users directly')
      return appState // Same reference!

    case 'UPDATE_USER':
      // Finding and mutating object properties
      const userIndex = appState.users.findIndex((user) => user.id === action.id)
      if (userIndex !== -1) {
        appState.users[userIndex].name = action.name
        appState.users[userIndex].updated = true
      }
      console.log('Mutated user object directly')
      return appState

    case 'SET_LOADING':
      // Mutating nested UI state
      appState.ui.loading = action.loading
      appState.ui.lastUpdate = Date.now()
      console.log('Mutated UI state directly')
      return appState

    default:
      return appState
  }
}

// Test the mutation approach
console.log('Initial state:', appState)
badReducer({ type: 'ADD_USER', user: { id: 2, name: 'Bob' } })
badReducer({ type: 'UPDATE_USER', id: 1, name: 'Alice Smith' })
badReducer({ type: 'SET_LOADING', loading: true })
console.log('Final state (same reference):', appState)
console.log('Breaks time-travel debugging and change detection')
✅ Pure reducer functions
// Pure reducer functions - proper Redux pattern
const initialState = { users: [{ id: 1, name: 'Alice' }], loading: false }

function userReducer(state = initialState, action) {
  switch (action.type) {
    case 'ADD_USER':
      console.log('Adding user:', action.user.name)
      return {
        ...state,
        users: [...state.users, action.user],
      }

    case 'UPDATE_USER':
      console.log('Updating user:', action.id)
      return {
        ...state,
        users: state.users.map((user) =>
          user.id === action.id ? { ...user, name: action.name, updated: true } : user
        ),
      }

    case 'REMOVE_USER':
      console.log('Removing user:', action.id)
      return {
        ...state,
        users: state.users.filter((user) => user.id !== action.id),
      }

    case 'SET_LOADING':
      console.log('Setting loading state:', action.loading)
      return { ...state, loading: action.loading }

    default:
      return state
  }
}

// Test the pure reducer approach
let state1 = userReducer(undefined, { type: '@@INIT' })
let state2 = userReducer(state1, {
  type: 'ADD_USER',
  user: { id: 2, name: 'Bob' },
})
let state3 = userReducer(state2, {
  type: 'UPDATE_USER',
  id: 1,
  name: 'Alice Smith',
})
let state4 = userReducer(state3, { type: 'SET_LOADING', loading: true })

console.log('Each state is a new object reference')
console.log('State 1 !== State 2:', state1 !== state2)
console.log('Enables time-travel debugging and change detection')
console.log(
  'Final users:',
  state4.users.map((u) => u.name)
)

Technical Trivia

The WhatsApp State Corruption Bug of 2017: WhatsApp Web suffered a critical bug where message history appeared corrupted for millions of users. The issue stemmed from a reducer that accidentally mutated nested message arrays instead of creating new ones, causing React to miss updates and display stale data.

Why mutations broke everything: React's reconciliation relies on reference equality to detect changes. When reducers mutate existing objects, React thinks nothing changed and skips re-renders. Users saw old messages while new ones were silently lost, creating the appearance of data corruption.

Pure reducers prevent state bugs: Redux DevTools can replay actions and show exact state changes when reducers are pure. Libraries like Immer make immutable updates easier by using Proxies to detect mutations, while Redux Toolkit includes Immer by default to prevent these common mistakes.


Master Redux Reducers: Implementation Strategy

Start with simple reducers that handle single action types, then combine them using combineReducers as your app grows. Always return new objects for state updates and use the spread operator or libraries like Immer for complex nested updates. Test reducers as pure functions by verifying that the same input always produces the same output.