Skip to content

Fix: Redux State Not Updating — Component Not Re-rendering

FixDevs ·

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 name

Or 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 dispatch

Or 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 undefined from a reducer — if a reducer has no explicit return and the switch default falls through, the state becomes undefined.
  • Wrong selectoruseSelector is 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 useCallback or useEffect closes over an old value of state, 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 state draft OR return a new value. Never both. If you return undefined, 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:

  1. Actions tab — is your action appearing? If not, dispatch isn’t reaching the store.
  2. Diff tab — does the state change after the action? If not, the reducer isn’t handling it.
  3. 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 match

Fix 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 null

Fix 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles