Back to the Library

React useReducer Action Not Working - Reducer Reads the Wrong Field

6 min read
“dispatch runs. The reducer runs - no error, no warning, it returns a brand new state object exactly like it's supposed to. And the result is wrong, because the reducer and the dispatch call were never reading from the same field.”

In This Post

The Reducer ContractField Name MismatchHardcoded Action TypeReading `state` Instead of `action`Practice These Patterns

Practice This Pattern

White Belt

Added Song Shows Up Blank

Adding a song to a playlist creates a new row, but the row is empty - the dispatch sends action.name, and the reducer reads action.title, which is undefined.

+10 KI
Enter the Dojo
Blue Belt

Unlike Button Keeps Adding Likes

A like counter only ever goes up, even when the button says Unlike - the click handler dispatches the same hardcoded action type every time, regardless of the current liked state.

+25 KI
Enter the Dojo
Blue Belt

First Apply Always Shows Zero Discount

Clicking a 20% discount button shows 0% on the first click and the previous discount on the second - the reducer computes the discount from state.code, the value before this action, instead of action.code.

+25 KI
Enter the Dojo
BugDojo
BlogFAQ

© 2026. Carved in code.

You click "Add Track." A new row appears in the playlist - the dispatch fired, the reducer ran, a new array with one more item came back, React re-rendered. Every mechanical step worked.

The new row is empty.

Nothing crashed. Nothing warned you. The reducer did exactly what it was written to do - it just wasn't written to do the thing the dispatch call was sending it.

The Reducer Contract

function reducer(state, action) {
  switch (action.type) {
    case "ADD":
      return { tracks: [...state.tracks, action.name] };
    default:
      return state;
  }
}

useReducer is built on a simple agreement: dispatch(action) sends an action object to the reducer. The reducer reads action.type to decide which case applies, then reads whatever other fields it needs from action to compute the new state. The dispatch call and the reducer are two separate places in your code, written possibly at different times - and nothing checks that they agree on what fields action actually has, beyond your own care in writing them consistently.

When they disagree, the result isn't an error. It's undefined, or the wrong value, slotted into the new state exactly where you asked for it.

Field Name Mismatch

function reducer(state, action) {
  switch (action.type) {
    case "ADD":
      return { tracks: [...state.tracks, action.title] };
    case "CLEAR":
      return { tracks: [] };
    default:
      return state;
  }
}
 
function handleAdd() {
  if (!input.trim()) return;
  dispatch({ type: "ADD", name: input });
  setInput("");
}

handleAdd dispatches { type: "ADD", name: input }. The action object has a name field, holding whatever the user typed.

The reducer's ADD case reads action.title - not action.name. action genuinely doesn't have a title field; it has name. action.title is undefined. [...state.tracks, action.title] appends undefined to the array - a new row exists, React renders it, and undefined renders as nothing. An empty row.

Symptom

The input clears, like a successful submission. A new row appears in the list - the array genuinely grew by one. The row itself is blank, with no indication of what went wrong, and it stays blank for every subsequent add too.

case "ADD":
  return { tracks: [...state.tracks, action.name] };

The fix is changing action.title to action.name - reading the field that the dispatch call actually sends. This is the simplest possible version of this bug: one field name, two places, and they didn't match.

The Sensei's Hint

Searching your codebase for everywhere a particular action.type is dispatched, and comparing the full shape of the action object against exactly what the reducer's matching case reads, catches this immediately. The mismatch is invisible at a glance because both action.name and action.title are completely valid-looking property accesses - JavaScript doesn't distinguish "a field that doesn't exist on this object" from "a typo," both just return undefined.

Hardcoded Action Type

A different way for dispatch and reducer to disagree: the reducer has correct, distinct logic for multiple action types - but the dispatch call never varies which one it sends.

function reducer(state, action) {
  switch (action.type) {
    case "LIKE":
      return { liked: true, count: state.count + 1 };
    case "UNLIKE":
      return { liked: false, count: state.count - 1 };
    default:
      return state;
  }
}
 
function handleClick() {
  dispatch({ type: "LIKE" });
}

The reducer's UNLIKE case is correct - it decrements count and sets liked to false. It is also completely unreachable. handleClick dispatches { type: "LIKE" } unconditionally, regardless of whether state.liked is currently true or false.

Click the button: liked becomes true, count becomes 1, the button now reads "Unlike." Click it again: handleClick still dispatches { type: "LIKE" } - the button's label changed, but the dispatch call doesn't look at that, or at state.liked, at all. count becomes 2. Every click, forever, dispatches LIKE and increments.

function handleClick() {
  dispatch({ type: state.liked ? "UNLIKE" : "LIKE" });
}

The fix makes the dispatched type conditional on the current state - exactly the condition the UI is already using to decide what label to show. If state.liked is true, dispatch UNLIKE; otherwise, dispatch LIKE. Now both reducer branches are reachable, in the order the UI implies they should be.

The Sensei's Hint

If your reducer has a case that handles a scenario your UI clearly supports - here, "un-liking" something that's already liked - and you can't find anywhere in the dispatch call that could produce that action type, the reducer logic is probably fine and the dispatch is the bug.

Reading state Instead of action

This last one is the subtlest, because both state and action are valid, real objects, both have a code field, and state.code is not a typo for anything - it's a real field that genuinely exists. It's just the wrong one for this calculation.

function reducer(state, action) {
  switch (action.type) {
    case "APPLY": {
      const discount = CODES[state.code] || 0;
      return {
        code: action.code,
        discount,
        total: PRICE - (PRICE * discount) / 100,
      };
    }
    default:
      return state;
  }
}

Starting state: { code: "", discount: 0, total: 100 }. Click "20% Off" - dispatches { type: "APPLY", code: "SAVE20" }.

Inside the reducer, state is the state before this action - { code: "", ... }. CODES[state.code] is CODES[""], which is undefined, so discount becomes 0. The returned state sets code: action.code (now correctly "SAVE20") but discount: 0 and total: 100 - unchanged.

Click "20% Off" again. Now state.code is "SAVE20" - the value set by the previous click. CODES["SAVE20"] is 20. This click finally shows 20% off. The discount is always exactly one click behind which button was pressed.

Constraints

Inside a reducer, state is "before this action" and action is "this action, happening now." A calculation that should reflect this dispatch

  • what code was just applied, what value was just entered, what item was just clicked - needs to read from action, not from state. Reading state for that gives you last time's answer, one step behind, which is easy to mistake for "off by one click" rather than "reading the wrong object."
case "APPLY": {
  const discount = CODES[action.code] || 0;
  return {
    code: action.code,
    discount,
    total: PRICE - (PRICE * discount) / 100,
  };
}

CODES[action.code] reads the code that was just dispatched - "SAVE20", right now, on this click. discount becomes 20 immediately, on the first click of any button, every time.

Practice These Patterns

"Added Song Shows Up Blank" is the field-name mismatch in its plainest form - one wrong identifier, one blank row, easy to fix once you compare the dispatch and the reducer side by side. "Unlike Button Keeps Adding Likes" moves the bug to the dispatch side entirely - the reducer is correct and untouched, and the fix is making the dispatched action type depend on current state. "First Apply Always Shows Zero Discount" is the hardest of the three: both state.code and action.code are valid fields on real objects, and the "off by one click" symptom can easily read as a timing or batching issue rather than what it actually is - reading from the wrong one of two very similarly-named things.