Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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. This is the core invariant that everything else follows from. Redux does not deep-compare state objects. It checks prevResult === nextResult (strict reference equality), and if the references are the same, it skips the re-render entirely. This design is intentional — deep comparison would be too slow for large state trees.

This reference equality check is where most “state not updating” bugs originate. You dispatch an action, the reducer runs, the state value changes in the store, but the component does not notice because the reference that useSelector returns is still the same object. The most common way this happens is by mutating the existing state object instead of creating a new one.

The second major cause is selector instability. If your selector creates a new object or array on every call (state => ({ ...state.user })), React-Redux re-renders on every dispatch regardless of whether the data changed. Conversely, if your selector returns the root state object (state => state), it never re-renders because the root reference doesn’t change even when nested values do.

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

Debugging Tools Compared

ToolWhat it showsBest for
Redux DevToolsAction log, state diff, time-travel, action replayVerifying actions reach the store and state changes correctly
React DevTools (Components tab)Component props, hooks state, re-render highlightsChecking whether the component receives the updated selector value
React DevTools (Profiler)Re-render timing, commit frequency, component render reasonsDiagnosing why a component re-renders too often or not at all
Custom middlewareAction interception, logging, error trackingProduction debugging where browser DevTools are unavailable

Custom logging middleware for production:

const loggerMiddleware = (store) => (next) => (action) => {
  console.group(action.type);
  console.log('prev state:', store.getState());
  const result = next(action);
  console.log('next state:', store.getState());
  console.groupEnd();
  return result;
};

const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(loggerMiddleware),
});

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
}, []);

Redux Toolkit vs Alternative State Libraries

If your Redux state management is causing persistent re-render issues, it may be worth understanding how other state libraries handle immutability and subscriptions differently.

Zustand

Zustand is the most direct Redux alternative. It uses a single store like Redux, but components subscribe to specific slices of state automatically. There is no reducer pattern — you mutate state directly inside set(), and Zustand uses Object.is() comparison by default. The key difference: Zustand does not wrap state in Immer, so you return a new object from set(), but the API is simpler because there are no actions, action types, or dispatch.

import { create } from 'zustand';

const useUserStore = create((set) => ({
  name: 'Bob',
  setName: (name) => set({ name }),  // Returns partial state — merged automatically
}));

// Component — only re-renders when 'name' changes
function UserName() {
  const name = useUserStore((state) => state.name);
  return <span>{name}</span>;
}

Zustand’s selector pattern is similar to useSelector, but subscription is per-component rather than per-store. If you select a primitive, the component re-renders only when that primitive changes — no shallowEqual needed.

Jotai

Jotai uses atoms (individual pieces of state) instead of a single store. Each atom is independent, and components subscribe to only the atoms they use. There is no global state tree, no reducers, and no actions. This eliminates the “wrong selector” class of bugs entirely because each atom is its own subscription. The trade-off is that you lose the single-store architecture that Redux DevTools relies on.

import { atom, useAtom } from 'jotai';

const nameAtom = atom('Bob');

function UserName() {
  const [name, setName] = useAtom(nameAtom);
  // Re-renders only when nameAtom changes — automatic
  return <span>{name}</span>;
}

MobX

MobX uses observable state and automatic dependency tracking. When you read an observable value inside a component wrapped in observer(), MobX tracks which observables the component depends on and re-renders only when those specific values change. You mutate state directly — MobX handles the immutability layer internally. This eliminates the “forgot to return a new object” class of bugs.

import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

class UserStore {
  name = 'Bob';
  constructor() { makeAutoObservable(this); }
  setName(name) { this.name = name; }  // Direct mutation — MobX tracks it
}

const UserName = observer(({ store }) => {
  return <span>{store.name}</span>;  // Re-renders when store.name changes
});

Valtio

Valtio brings MobX-style proxy-based reactivity to a simpler API. You create a proxy object, mutate it directly, and components subscribe using useSnapshot(). The snapshot is an immutable view of the proxy’s current state. Valtio handles the reference equality problem internally.

import { proxy, useSnapshot } from 'valtio';

const state = proxy({ name: 'Bob' });

function UserName() {
  const snap = useSnapshot(state);
  return <span>{snap.name}</span>;
}

// Update — mutate the proxy directly
state.name = 'Alice';

RTK Query vs TanStack Query

For server state (data fetched from APIs), the “state not updating” problem is often better solved by a dedicated data-fetching library rather than Redux:

RTK Query (part of Redux Toolkit) integrates with the Redux store and DevTools. It handles caching, refetching, and cache invalidation. State updates are automatic — when data is refetched, the cache updates and subscribed components re-render.

TanStack Query (formerly React Query) is framework-agnostic and manages server state independently from client state. It does not use Redux at all. If your “Redux state not updating” bug involves API data, TanStack Query eliminates the problem by managing that data outside Redux.

When to stay with Redux: Redux is the right choice when you have complex client-side state that many components share (user sessions, UI state, multi-step forms). For server-fetched data, RTK Query or TanStack Query handle the caching and synchronization better than manual Redux reducers.

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.

Check for React strict mode double-rendering. In development, React 18+ runs effects and renders twice to detect side effects. This can mask stale closure bugs that only appear in production. Test with strict mode disabled to isolate the issue, then fix the root cause.

Verify you are not dispatching inside a render. Dispatching an action during the render phase causes React to schedule a re-render while one is already in progress. This can lead to stale state and missing updates:

// BROKEN — dispatch during render
function UserDisplay() {
  const user = useSelector(state => state.user);
  if (!user.loaded) {
    dispatch(fetchUser());  // Dispatch during render — causes issues
  }
  return <span>{user.name}</span>;
}

// CORRECT — dispatch in useEffect
function UserDisplay() {
  const user = useSelector(state => state.user);
  useEffect(() => {
    if (!user.loaded) {
      dispatch(fetchUser());
    }
  }, [user.loaded, dispatch]);
  return <span>{user.name}</span>;
}

Check useSelector identity with React.memo. If a parent component re-renders and passes new props to a memoized child, the child re-renders even if its own selector hasn’t changed. Use React.memo on the child only if it receives primitive props or memoized objects.

For related state management issues, see Fix: React Context Not Updating, Fix: React Too Many Re-renders, Fix: React useEffect Infinite Loop, and Fix: Zustand Not Working.

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