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.”
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.
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.
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.
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.
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.
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.
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.
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."
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.
"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.