Redux State Not Updating? A Diagnostic Guide to Reducer Mutation Bugs
Dispatch fires but the UI stays stale? Diagnose accidental state mutations in Redux reducers with reference-equality checks, DevTools traces, and targeted immutable-update fixes.
06 Nov 2025, 19:25 UTC

The symptom: dispatch succeeds, UI stays stale
You dispatch an action, the reducer runs, and yet the connected component never re-renders — or Redux DevTools shows the action fired but the state diff is empty. In almost every case, the cause is the same family of bug: something mutated the existing state object instead of returning a new one. React-Redux's useSelector and connect use reference equality (===) to decide whether to re-render, so a mutated object with the same reference looks like "nothing changed." This guide walks through how to confirm the diagnosis and which fix matches which finding.
Cause and diagnostic table
| Cause | What you observe | Quick check |
|---|---|---|
Direct mutation in reducer (push, splice, property assignment) | State reference identical before/after dispatch; no re-render | Compare state === nextState in a subscription |
| Nested mutation without spreading parent objects | Some selectors update, others don't; shallow equality fails at the parent level | DevTools diff shows change but parent node reference is unchanged |
Middleware mutating action.payload before the reducer | Reducer looks correct but state is still corrupted upstream | Log the action at the start and end of the middleware chain |
Reducer returns undefined for an unhandled action | Store throws on init, or a slice silently becomes undefined | Check the default case returns state |
Ordered checks
- Check reference equality first. In your app entry file (development only), add:
Warnings during flows that should change state confirm a mutation bug. No special permissions needed; remove before shipping.let prev = store.getState(); store.subscribe(() => { const next = store.getState(); if (prev === next) console.warn('Dispatch produced same state reference'); prev = next; }); - Enable the immutability middleware. Redux Toolkit's
configureStoreincludesredux-immutable-state-invariantby default in development — it deep-freezes state and throws on mutation. If you have a custom middleware array, make sure you haven't disabled it. Note: deep-freezing is expensive on large state trees, so it must stay out of production builds (Toolkit handles this automatically viaNODE_ENV). - Use the DevTools Trace tab. In Redux DevTools, the Trace tab shows the stack trace for each dispatch, which pinpoints exactly which reducer line ran. Combine it with the Diff view: if the action fired but the diff is empty, suspect reference equality.
- Inspect the action through the middleware chain. If the reducer code looks clean, write a pass-through logger placed first in the middleware array to capture the action before anything touches it, and compare with what the reducer receives. Middleware order matters: immutability checks go early, loggers go last.
Fixes matched to findings
Direct mutation in a plain reducer
Replace mutating array methods with copying ones:
// Before: mutates
state.items.push(action.payload);
// After: new array, new reference
return { ...state, items: [...state.items, action.payload] };Nested updates
Every level of the path to the changed value needs a new object. In a plain reducer:
return {
...state,
session: {
...state.session,
user: { ...state.session.user, name: action.payload }
}
};This gets unwieldy fast, which is the main argument for Redux Toolkit's createSlice. It wraps reducers in Immer, so "mutating" syntax is translated into immutable updates:
const sessionSlice = createSlice({
name: 'session',
initialState,
reducers: {
renameUser(state, action) {
state.user.name = action.payload; // safe: Immer draft
}
}
});One caveat: don't mix Immer draft mutation with manual spreads returning a new object in the same case reducer — either mutate the draft or return a new value, never both.
Middleware mutating the payload
Clone before transforming:
const enrich = store => next => action => {
if (action.type === 'items/add') {
return next({ ...action, payload: { ...action.payload, ts: Date.now() } });
}
return next(action);
};Missing default case
Every reducer must return the incoming state for unknown actions: default: return state;. With TypeScript, strictNullChecks helps catch branches that fall through to undefined.
Verifying the fix
Two practical checks. First, add a deliberate mutation to a test reducer and confirm the dev middleware throws — this proves your safety net actually works. Second, after applying the fix, dispatch the same actions in DevTools and confirm each state node now shows a distinct reference with a visible diff. In CI, keep the immutability middleware enabled in the test environment so regressions fail the build rather than shipping silently.
When to escalate
If mutations persist after the reducer is provably immutable, look outside it: persistence layers that rehydrate and then modify state, analytics or sync libraries holding store references, or code reading store.getState() and mutating the result. Also verify configureStore isn't disabling the default middleware checks:
configureStore({
reducer: rootReducer,
middleware: (getDefault) =>
getDefault({ immutableCheck: process.env.NODE_ENV !== 'production' })
});Assumes Redux Toolkit 1.x/2.x and React-Redux 8+; older hand-rolled stores need redux-immutable-state-invariant installed explicitly.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.