Fix: Redux State Not Updating — Component Not Re-rendering
Quick Answer
How to fix Redux state not updating in components — mutating state directly, stale selectors, missing immer patterns in Redux Toolkit, useSelector mistakes, and debugging with Redux DevTools.
The Error
A Redux action dispatches successfully but the component doesn’t re-render with the new state:
dispatch(setUser({ name: 'Alice' }));
console.log(store.getState().user.name); // "Alice" ✓ — state updated
// But the component still shows the old nameOr the state appears to update in DevTools but the component stays stale:
// Component
const user = useSelector(state => state.user);
// user.name still shows "Bob" even after dispatchOr a Redux Toolkit reducer doesn’t seem to work:
// Reducer that looks correct but doesn't trigger re-render
reducers: {
updateName(state, action) {
state.user.name = action.payload; // Seems right with Immer
// But component doesn't update
}
}Why This Happens
Redux re-renders components only when useSelector returns a different value by reference. Several patterns break this:
- Direct state mutation — modifying the existing state object instead of returning a new one. Redux uses reference equality; if the object reference doesn’t change, React doesn’t re-render.
- Returning
undefinedfrom a reducer — if a reducer has no explicit return and theswitchdefault falls through, the state becomesundefined. - Wrong selector —
useSelectoris called with a selector that always returns the same reference (e.g., returning the entire root state object). - Immer mutation outside Redux Toolkit — Immer’s draft proxy is only available inside Redux Toolkit reducers. Trying to mutate outside of Immer won’t work.
- Missing
createSlice/createReducer— using manual reducers without Immer requires returning a new object; mutation doesn’t work. - Action type mismatch — dispatching an action with a type that doesn’t match any reducer case.
- Component reading stale closure — a
useCallbackoruseEffectcloses over an old value ofstate, even after the store updates.
Fix 1: Don’t Mutate State — Return a New Object
In plain Redux (without Redux Toolkit’s Immer), you must return a new object. Mutating the existing state object won’t trigger re-renders:
// BROKEN — mutates the existing state object
function userReducer(state = initialState, action) {
switch (action.type) {
case 'SET_USER':
state.name = action.payload.name; // Mutation — same reference
return state; // Same object → no re-render
}
}
// CORRECT — return a new object with spread
function userReducer(state = initialState, action) {
switch (action.type) {
case 'SET_USER':
return {
...state, // Copy existing state
name: action.payload.name, // Override changed fields
};
default:
return state;
}
}Nested objects also need new references at every level:
// BROKEN — nested mutation
case 'UPDATE_ADDRESS':
state.user.address.city = action.payload; // Mutates nested object
return { ...state }; // state.user still same reference
// CORRECT — spread at every level
case 'UPDATE_ADDRESS':
return {
...state,
user: {
...state.user,
address: {
...state.user.address,
city: action.payload,
},
},
};Fix 2: Use Redux Toolkit (Immer) Correctly
Redux Toolkit uses Immer, which allows direct mutation inside createSlice reducers. But Immer only works inside the reducer function — not outside, and not in async code:
import { createSlice } from '@reduxjs/toolkit';
const userSlice = createSlice({
name: 'user',
initialState: { name: '', address: { city: '' } },
reducers: {
// CORRECT — Immer allows mutation inside reducer
setName(state, action) {
state.name = action.payload; // Immer creates a new object internally
},
// CORRECT — nested mutation also works
setCity(state, action) {
state.address.city = action.payload;
},
// BROKEN — returning AND mutating: pick one
setNameBroken(state, action) {
state.name = action.payload; // Mutation...
return { ...state }; // ...AND return. Immer ignores mutation if you return
},
// CORRECT — if you return, return the complete new state
setNameReturn(state, action) {
return { ...state, name: action.payload }; // Return only — no mutation
},
},
});Immer rule: Either mutate the
statedraft OR return a new value. Never both. If you returnundefined, Immer uses the mutation. If you return a value, Immer uses the returned value and ignores mutations.
Fix 3: Fix useSelector to Avoid Returning Same Reference
useSelector uses strict reference equality (===) by default. If the selector returns the same object reference even when the data changes, React won’t re-render:
// BROKEN — returns the entire state object
// Reference doesn't change even when state.user.name changes
const state = useSelector(state => state);
console.log(state.user.name); // Stale — doesn't re-render
// BROKEN — returns a new object every render (always triggers re-render)
const user = useSelector(state => ({ ...state.user })); // New ref every time
// CORRECT — return a primitive or a stable reference
const userName = useSelector(state => state.user.name); // Primitive — works
const userId = useSelector(state => state.user.id); // Primitive — works
// CORRECT — for objects, use shallowEqual
import { shallowEqual } from 'react-redux';
const user = useSelector(
state => state.user,
shallowEqual // Re-renders only when shallow properties change
);Use RTK’s createSelector for derived data:
import { createSelector } from '@reduxjs/toolkit';
// Memoized selector — only recomputes when input changes
const selectActiveUsers = createSelector(
state => state.users.list,
users => users.filter(u => u.active)
);
function UserList() {
// Only re-renders when the filtered list actually changes
const activeUsers = useSelector(selectActiveUsers);
return <ul>{activeUsers.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}Fix 4: Fix Missing Default Case in Reducer
Every Redux reducer must handle the default case and return the current state. Omitting it causes state to become undefined:
// BROKEN — no default case
function counterReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
// Missing default → returns undefined for unrecognized actions
}
}
// CORRECT
function counterReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
default:
return state; // Always return current state for unknown actions
}
}Redux Toolkit’s createSlice handles this automatically — you don’t need a default case in RTK reducers.
Fix 5: Debug with Redux DevTools
Redux DevTools (browser extension) shows every action dispatched and the state before/after:
// Verify DevTools is connected
import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
reducer: rootReducer,
devTools: process.env.NODE_ENV !== 'production', // Enable in dev
});In DevTools, check:
- Actions tab — is your action appearing? If not, dispatch isn’t reaching the store.
- Diff tab — does the state change after the action? If not, the reducer isn’t handling it.
- State tab — is the new state correct? If state is correct but component is stale, the selector is wrong.
Check action type strings match:
// With createSlice, use the auto-generated action creator
const { setName } = userSlice.actions;
dispatch(setName('Alice'));
// Action type: "user/setName"
// Don't manually type action types — easy to mismatch
dispatch({ type: 'user/setname', payload: 'Alice' }); // Wrong case — doesn't matchFix 6: Fix Async Thunk State Updates
With createAsyncThunk, state updates happen in extraReducers. A common mistake is handling the wrong lifecycle:
const fetchUser = createAsyncThunk('user/fetch', async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
});
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload; // The resolved value from the thunk
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});Common mistake — dispatching the thunk but not connecting it to extraReducers:
// Dispatch works, but state never updates because extraReducers isn't configured
dispatch(fetchUser(userId));
// user.data stays nullFix 7: Fix Stale Closures in useCallback and useEffect
If an effect or callback closes over Redux state, it may hold a stale value even after the store updates:
// STALE — closes over the initial value of 'count'
const handleClick = useCallback(() => {
console.log(count); // Always logs the initial value
}, []); // Missing 'count' in dependencies
// CORRECT — include 'count' in dependencies
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
// BETTER — read directly from the store when needed
const handleClick = useCallback(() => {
const currentCount = store.getState().counter.count;
console.log(currentCount); // Always fresh
}, []);Or use useRef to always have the latest value without triggering re-renders:
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // Keep ref in sync with Redux state
}, [count]);
const handleClick = useCallback(() => {
console.log(countRef.current); // Always fresh, no stale closure
}, []);Still Not Working?
Verify the store is provided. If useSelector returns undefined or the initial state, the component might not be inside <Provider>:
// main.tsx — Provider must wrap the entire app
import { Provider } from 'react-redux';
import { store } from './store';
createRoot(document.getElementById('root')).render(
<Provider store={store}>
<App />
</Provider>
);Check for multiple store instances. If you import store and also pass it to <Provider>, but somewhere in the tree you create a second store, components may be reading from the wrong one.
Log the selector output:
const result = useSelector(state => {
console.log('Selector running, state:', state.user);
return state.user.name;
});If the selector runs but the component doesn’t re-render, the returned value is the same as before (reference equality check passes). If the selector doesn’t run at all, the component is not subscribed to the store.
For related state management issues, see Fix: React Context Not Updating and Fix: React Too Many Re-renders.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Mapbox GL JS Not Working — Map Not Rendering, Markers Missing, or Access Token Invalid
How to fix Mapbox GL JS issues — access token setup, React integration with react-map-gl, markers and popups, custom layers, geocoding, directions, and Next.js configuration.
Fix: React PDF Not Working — PDF Not Rendering, Worker Error, or Pages Blank
How to fix react-pdf and @react-pdf/renderer issues — PDF viewer setup, worker configuration, page rendering, text selection, annotations, and generating PDFs in React.
Fix: React Three Fiber Not Working — Canvas Blank, Models Not Loading, or Performance Dropping
How to fix React Three Fiber (R3F) issues — Canvas setup, loading 3D models with useGLTF, lighting, camera controls, animations with useFrame, post-processing, and Next.js integration.