Home JavaScript Redux with React: Missing Cart Items from Async Thunks
Advanced 12 min · March 05, 2026
Redux with React Toolkit

Redux with React: Missing Cart Items from Async Thunks

Items vanish from cart when async thunks complete out of order.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 30 min
  • Deep production experience
  • Understanding of internals and trade-offs
  • Experience debugging complex systems
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Redux with React: a predictable state container enforcing unidirectional data flow via a single store, pure reducers, and dispatched actions.
  • Components read state through useSelector, which subscribes to store changes and triggers re-renders.
  • Actions are plain objects describing what happened; reducers compute the next state immutably.
  • Middleware (thunks, sagas) intercepts dispatched actions for side effects like API calls.
  • Performance: useSelector re-renders on every store change by default — memoized selectors prevent wasted renders.
  • Production trap: mutating state inside a reducer silently breaks time-travel debugging and causes unpredictable UI.
  • RTK Query eliminates manual thunks for data fetching — tag-based invalidation prevents stale data.
✦ Definition~90s read
What is Redux with React Toolkit?

Redux is a predictable state container for JavaScript apps, most commonly paired with React to manage global state that multiple components need to read or update. It solves the problem of prop drilling and inconsistent state by centralizing all application state into a single store, which components access via subscriptions.

Imagine your entire React app is a busy restaurant.

In a React-Redux app, the store dispatches actions (plain objects with a type field) through reducers—pure functions that compute the next state immutably. This architecture enforces a unidirectional data flow: UI dispatches an action → reducer produces new state → subscribed components re-render.

It’s overkill for simple apps (React’s useContext + useReducer often suffices), but essential when you have complex state interactions across many components, like in e-commerce or dashboard UIs with hundreds of moving parts.

Under the hood, Redux uses a single store object and a subscribe pattern. React-Redux’s useSelector hook taps into this by running a selector function against the store on every dispatch, then comparing the result with a strict === reference check.

If the selector returns a new reference (e.g., a new array from .map()), the component re-renders—even if the data is logically unchanged. This is the #1 source of unnecessary re-renders in production Redux apps. Middleware like Redux Thunk (for async logic) or Redux Saga (for complex side-effect orchestration) sits between dispatch and the reducer, allowing you to handle API calls, timers, or websocket events without polluting components.

Redux Toolkit (RTK) is now the official standard because it wraps this boilerplate: it generates action creators, uses Immer for immutable updates, and includes createAsyncThunk for handling loading/error states in a single pattern.

In production, common pitfalls include: storing derived data in the store (compute it with useMemo or Reselect instead), over-normalizing state (flatten deeply nested data only when necessary), and forgetting that useSelector re-runs on every dispatch. A typical bug: dispatching a thunk that fetches cart items, but the component doesn’t update because the selector returns a new array reference each time (e.g., state.cart.items vs. a memoized selector).

RTK’s createEntityAdapter helps here by providing normalized selectors with stable references. If you’re seeing missing cart items after an async thunk completes, it’s almost always a selector reference issue or a race condition where the thunk’s fulfilled action isn’t being handled by the correct reducer slice.

Plain-English First

Imagine your entire React app is a busy restaurant. Every waiter (component) needs to know the current orders, table availability, and kitchen status. Without a system, waiters run around asking each other — chaos. Redux is the restaurant's central whiteboard: every update goes on the board, and every waiter reads from it. No waiter tells another waiter anything directly. One source of truth, zero miscommunication.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

State management is the silent killer of React applications at scale. A cart component needs to know if the user is logged in. A header needs the cart count. A checkout page needs both. Prop-drilling turns your component tree into a telephone game, and React Context re-renders everything that consumes it whenever anything changes. At some point, you need a disciplined, predictable system — and that is exactly why Redux exists.

Redux solves a specific, hard problem: how do you make state changes predictable and traceable in a large application with many actors reading and writing to shared state? It enforces a unidirectional data flow — state only changes through dispatched actions, processed by pure reducer functions — making every state transition auditable, time-travelable, and testable in isolation. That is not just architecture astronautics; it is the difference between a bug you can reproduce in 30 seconds versus one you chase for three days.

By the end of this article you will understand how the Redux store actually works under the hood, why useSelector is deceptively tricky in production, how Redux Toolkit eliminates the boilerplate without hiding the model, and how to structure a real feature slice in a way that will not embarrass you in a code review. We will also cover the performance traps that bite even experienced engineers and the questions interviewers use to separate people who have read the docs from people who have shipped with Redux.

Here's the thing: Redux isn't magic. It's a handful of simple patterns executed well. The complexity appears when you don't follow them.

How Redux with React Manages State Across Components

Redux with React is a state management pattern where a single JavaScript object tree (the store) holds all application state, and React components read slices of that state via selectors. The core mechanic is unidirectional data flow: components dispatch actions, reducers compute new state immutably, and React re-renders only the components that depend on changed state. This eliminates prop drilling and gives you a predictable, debuggable state lifecycle.

In practice, Redux uses a centralized store, pure reducer functions, and middleware like Redux Thunk for async logic. The store is created once and passed to the React tree via a Provider component. Components use hooks like useSelector and useDispatch to read state and trigger updates. The key property is that state transitions are explicit and replayable — every action is a plain object, every reducer is a pure function, making time-travel debugging and logging trivial.

Use Redux with React when multiple components share state that changes frequently, or when you need a single source of truth for complex data flows like authentication, shopping carts, or real-time dashboards. It matters in production because it enforces discipline: state mutations are centralized, side effects are isolated in middleware, and performance is optimized via memoized selectors. For apps with moderate to high state complexity, Redux prevents the spaghetti of scattered setState calls and context re-renders.

🔥Redux is not React-specific
Redux is a standalone state container; React bindings are just one integration. You can use Redux with Vue, Angular, or even vanilla JS.
📊 Production Insight
A food delivery app lost cart items because an async thunk dispatched a success action after the user navigated away, mutating stale state.
Symptom: users saw empty carts or duplicate items after rapid navigation; Redux DevTools showed actions arriving out of order.
Rule: always cancel in-flight thunks on unmount using AbortController or a takeLatest pattern to avoid stale async updates.
🎯 Key Takeaway
Redux gives you a single source of truth, but only if you keep reducers pure and actions serializable.
Async thunks must be cancelled on component unmount to prevent stale state updates.
Use memoized selectors (createSelector) to avoid unnecessary re-renders in large component trees.
redux-with-react THECODEFORGE.IO Redux Async Thunk Data Flow How missing cart items propagate through Redux with React Component Dispatch React component dispatches fetchCartItems thunk Thunk Execution Async thunk calls API and returns payload Reducer Update Reducer updates store with cart items array Store State Change Redux store holds new cart items state useSelector Re-render Component re-renders due to selector subscription Missing Items Bug Selector returns stale or empty array if memoization fails ⚠ Selector returns empty array if memoization breaks Use createSelector to cache derived data THECODEFORGE.IO
thecodeforge.io
Redux With React

The Redux Store: Architecture and Internal Data Flow

The store is created with createStore(reducer, preloadedState, enhancer) (legacy) or configureStore from Redux Toolkit. Under the hood, the store holds the current state tree, exposes getState(), dispatch(action), and subscribe(listener). When you dispatch an action, the store calls the root reducer with the current state and the action, receives the new state, and notifies all subscribers. React-Redux uses subscribe internally to force re-renders of connected components. The enhancer parameter (applied via applyMiddleware) wraps dispatch to allow middleware chains — each middleware intercepts actions before they reach the reducer.

Modern Redux Toolkit (RTK) uses configureStore which automatically combines reducers, adds middleware (including redux-thunk by default), and enables the Redux DevTools. It also uses Immer inside createSlice to let you write mutable-looking reducer logic that gets converted to immutable updates.

One detail most docs skip: configureStore wraps the middleware array with getDefaultMiddleware(). If you replace the middleware array entirely without including the defaults, you lose thunk support and the serializable check. Teams have shipped to production without realising they dropped async dispatch support — the app ran but thunks silently failed. Always spread defaults: middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(customMiddleware).

Another subtlety: the order of middleware matters. If you insert a logger before thunk, you'll see the action twice — once when the thunk function is dispatched, and again when the thunk dispatches internal actions. For error handling, you want your error middleware after thunk so it catches async errors too.

I've seen a team spend a day debugging why their thunk actions weren't triggering API calls — they had accidentally overridden the default middleware with an empty array. That's the kind of silent failure that makes you check the middleware config first on every new store setup.

store.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge.redux.config
import { configureStore, createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1; },
    decrement: (state) => { state.value -= 1; },
    incrementByAmount: (state, action) => { state.value += action.payload; },
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;

export const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(logger),
});
Try it live
⚠ Middleware Array Gotcha
Never replace the entire middleware array without spreading defaults. Use: getDefaultMiddleware().concat(myMiddleware). Otherwise you lose thunk and DevTools serialization checks.
📊 Production Insight
A team used configureStore without explicitly adding thunk middleware (it's included by default) and then tried to dispatch async functions.
It worked because RTK includes thunk by default, but when they later added custom middleware, they accidentally removed thunk.
Rule: always check the middleware array when adding custom middleware to RTK configureStore.
Pro tip: log the middleware array at startup to confirm your chain is intact.
🎯 Key Takeaway
configureStore from RTK replaces createStore.
It bundles common middleware and enables DevTools.
Be explicit about middleware order when extending.
Manual Middleware Addition Decision
IfAdding logging middleware
UseUse concat() at the end - order doesn't matter for logging
IfAdding custom error-catching middleware
UseInsert before the thunk middleware to catch all dispatch errors
IfMigrating from createStore to configureStore
UsePass existing middleware via middleware option - spread defaults first

useSelector: How It Works and Why It Re-renders

useSelector(selector, equalityFn) subscribes the component to the store. On every dispatch, React-Redux runs the selector function with the current store state. If the result differs from the previous result (by default using === reference equality), the component re-renders. This is where most performance bugs originate. Creating new arrays or objects inside the selector on every call — useSelector(state => state.items.filter(...)) — produces a new reference every time, causing infinite re-renders even if the underlying data hasn't changed.

The fix is to either memoize the derived data with createSelector from Reselect, or use shallow equality by passing shallowEqual as the second argument. For simple leaf values (state.counter.value), reference equality works fine. But for derived data, always memoize.

React-Redux v8+ uses useSyncExternalStore under the hood, which is more efficient and avoids tearing during concurrent rendering.

Here's the trap that catches teams new to hooks: passing a function reference as the selector argument directly inside the component creates a new function on every render. That's fine for React-Redux because it uses the selector itself to compute values. But the real problem is when the selector returns a new reference. Slice selectors that return parts of the state tree from nested objects can also cause issues if you return a sub-object directly — it's still the same reference unless you spread. Use shallowEqual if you must return an object: useSelector(state => ({ name: state.user.name, age: state.user.age }), shallowEqual).

One more nuance: if you select from deeply nested state without memoization, your component re-renders even when a completely unrelated slice changes. That's why you should keep selectors as granular as possible. Think of it like a SQL query — select only the columns you need.

A common debugging trick: add a console.log inside the component body to see how often it renders. If it logs on every store dispatch, your selector is problematic. I've seen a list component re-render 100 times for a single state change because the selector was returning a new array — the fix was a one-line createSelector.

Counter.jsxJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { increment } from './store';

function Counter() {
  const value = useSelector(state => state.counter.value);
  const dispatch = useDispatch();
  return (
    <div>
      <span>{value}</span>
      <button onClick={() => dispatch(increment())}>+</button>
    </div>
  );
}

// BAD: new array on every render
const items = useSelector(state => state.todos.filter(t => t.completed));

// GOOD: memoized selector
import { createSelector } from '@reduxjs/toolkit';
const selectCompletedTodos = createSelector(
  state => state.todos,
  todos => todos.filter(t => t.completed)
);
const items = useSelector(selectCompletedTodos);
Try it live
Mental Model
Selector as a Lens
Think of a selector as a lens that focuses on a specific part of the state. Each time the store changes, the lens re-evaluates whether the view changed.
  • A selector that returns a new object on every call is like a lens that shakes - the view appears to change even when it hasn't
  • createSelector builds a stabilised lens that only recomputes when inputs change
  • shallowEqual is like using a quick check on the surface of the object before deciding to re-render
  • Always memoize when the selector does computation or transformation
📊 Production Insight
A team had a selector that returned a mapped array: useSelector(state => state.users.map(u => u.name)).
Every dispatch to any part of the store caused this component to re-render because .map() returns a new array.
Rule: use createSelector for any derived data to memoize until inputs change.
Silent performance killer: a parent component re-renders a list of child components, each with an unmemoized selector — CPU spikes for no reason.
🎯 Key Takeaway
useSelector re-renders when its return value changes by reference.
Memoize derived data with createSelector.
Pass shallowEqual for objects that are structurally equal.
Debugging useSelector Re-renders
IfComponent re-renders on every dispatch
UseCheck if selector returns a new reference each time - wrap with createSelector
IfComponent doesn't re-render when data changes
UseVerify that the selector is returning the correct slice and that the component is subscribed
IfSelector uses .filter, .map, or object literal
UseDefinitely memoize - these always return new references
IfSelecting a nested object from state
UseUse shallowEqual as second argument to avoid deep reference checks
redux-with-react THECODEFORGE.IO Redux Store Architecture with Thunks Layered structure from React components to async middleware React Components CartList | CartItem | CheckoutButton Hooks Layer useSelector | useDispatch Redux Store State Tree | Reducers | Actions Middleware Redux Thunk | Logger Async Side Effects API Calls | Local Storage THECODEFORGE.IO
thecodeforge.io
Redux With React

Middleware: Handling Side Effects with Thunks and Sagas

Redux middleware intercepts every dispatched action before it reaches the reducer. This is where side effects — API calls, timers, logging — belong. The most common middleware is Redux Thunk (included with RTK). A thunk is a function that returns another function with (dispatch, getState) parameters. You can dispatch multiple actions from within a thunk to handle loading states.

Redux Saga (a separate middleware) uses ES6 generators for more complex orchestration. It can watch for actions, fork tasks, cancel them, and handle race conditions. Sagas are powerful but add another layer of abstraction. For 90% of apps, thunks are sufficient.

RTK Query (part of RTK) eliminates the need for hand-written thunks for data fetching. It auto-generates hooks like useGetPostsQuery and manages caching, loading states, and cache invalidation out of the box.

A hidden cost of sagas: every saga runs on the main thread. A long-running saga that blocks can freeze the UI if not properly yielded. Also, sagas are hard to type — the generator yield types are not checked by TypeScript in the same way as thunks. If your team struggles with debugging, thunks plus RTK Query are usually the safer bet.

Something I've seen multiple times: teams use takeLatest for search but forget to cancel the underlying HTTP call. The user types 'ab', the first request goes out, then 'abc' triggers a new one. The first response arrives after the second, overwriting the correct result. That's a classic race condition. With sagas you can cancel the first request's generator, but the browser's fetch is still in flight. Always pair with AbortController.

Another trap: dispatching a synchronous action inside a thunk before the async call is fine, but be careful not to dispatch too many actions in a single thunk — each dispatch triggers a full store update and subscriber cycle. Batch related updates into a single action if possible.

thunkExample.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// io.thecodeforge.redux.thunk
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { api } from './api';

export const fetchUser = createAsyncThunk(
  'users/fetchById',
  async (userId, { rejectWithValue }) => {
    try {
      const response = await api.get(`/users/${userId}`);
      return response.data;
    } catch (err) {
      return rejectWithValue(err.response.data);
    }
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState: { entity: null, loading: 'idle', error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => { state.loading = 'loading'; })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = 'idle';
        state.entity = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = 'idle';
        state.error = action.payload;
      });
  },
});

export default userSlice.reducer;
Try it live
⚠ Saga Cancellation Trap
takeLatest cancels the saga task but not the underlying HTTP request. Use AbortController with fetch or axios.CancelToken to truly cancel in-flight requests.
📊 Production Insight
A saga was using takeLatest for a search function, but the generator inside was not canceling the previous API call properly — it only cancelled the saga task, not the in-flight XMLHttpRequest.
Rule: when cancellation matters, use a cancellable API (e.g., axios.CancelToken or AbortController) in combination with saga cancellation.
Pro tip: RTK Query handles this automatically with its query cancellation — another reason to prefer it.
🎯 Key Takeaway
Thunks are simple and sufficient for most apps.
Sagas offer advanced orchestration at the cost of complexity.
RTK Query removes most of the boilerplate for data fetching.
Always cancel in-flight requests, not just saga tasks.

Redux Toolkit: Why It’s the Standard and How It Works

Redux Toolkit (RTK) is the officially recommended way to write Redux logic. It wraps createStore with sensible defaults: combined reducers, built-in thunk middleware, DevTools enabled, and Immer integration. createSlice auto-generates action creators and action types from a reducer map, drastically reducing boilerplate. createAsyncThunk standardizes the pattern for async requests with pending/fulfilled/rejected states.

RTK also introduces createReducer which uses Immer under the hood so you can write mutable logic that becomes immutable automatically. This eliminates the most common source of bugs: forgetting to spread or copy state.

RTK Query, an optional add-on, eliminates the need for any manual action creators, reducers, or selectors for API calls. It generates hooks that manage caching, polling, optimistic updates, and cache invalidation based on tag systems.

One underappreciated feature: RTK Query's tag-based invalidation lets you express complex refetch rules without manual logic. For example, a 'Post' tag can be invalidated after a 'like' mutation, triggering a refetch of the post list. This avoids stale data without polling. But beware of over-invalidation — if you invalidate too broadly, you refetch everything on every mutation, killing performance.

Another practical tip: RTK's createAction and createReducer are great for migrating legacy Redux code incrementally. You don't have to rewrite everything at once — you can adopt slice by slice.

I've migrated a large codebase from legacy createStore to RTK piece by piece. The key is to start with configureStore and then replace one reducer at a time with createSlice. No big bang, no breaking everything at once.

apiSlice.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// io.thecodeforge.redux.rtkquery
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const apiSlice = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    addPost: builder.mutation({
      query: (newPost) => ({
        url: '/posts',
        method: 'POST',
        body: newPost,
      }),
      invalidatesTags: ['Post'],
    }),
  }),
});

export const { useGetPostsQuery, useAddPostMutation } = apiSlice;
Try it live
🔥RTK Query Cache Tags
Tag-based invalidation is the soul of RTK Query. Use providesTags on queries and invalidatesTags on mutations. This tells RTK Query exactly when to refetch.
📊 Production Insight
A team migrated to RTK but kept using manual createStore because they didn't trust configureStore. They missed out on middleware and DevTools setup, leading to wasted hours debugging.
Rule: use configureStore from RTK — it's the standard and includes all production needs by default.
Real example: a developer once spent a day debugging why DevTools weren't showing state changes — turns out they had devTools: false in the options. Always check your config.
🎯 Key Takeaway
RTK eliminates boilerplate and mutation bugs with createSlice and Immer.
RTK Query replaces thunks for data fetching.
Always use configureStore over legacy createStore.
Check your config — DevTools, middleware, and serializableCheck matter.
RTK Query vs Manual Thunks
IfSimple CRUD with standard REST endpoints
UseUse RTK Query - auto-generates reducers, selectors, and cache management
IfComplex API interactions with custom transform logic
UseRTK Query may be too restrictive - use manual createAsyncThunk for full control
IfNeed optimistic updates with rollback
UseRTK Query supports onQueryStarted with optimistic updates - use it
IfMultiple endpoints share cache (e.g., 'Post' list and 'Post' detail)
UseRTK Query's tag system handles this elegantly with providesTags and invalidatesTags

Common Production Pitfalls with Redux

Even with tooling, teams hit recurring issues. Here are the patterns that cause the most outages:

  1. State mutation inside reducers — even with spread operators, deep nested updates are error-prone. Solution: use createSlice (Immer) or manual deep cloning.
  2. Blindly using useSelector with inline functions — every render creates a new function, but the real problem is when the selector returns a new reference (like filtering). Solution: memoize with createSelector.
  3. Dispatching too many actions synchronously — each dispatch triggers a store update and re-render of all subscribers. Batch related dispatches with unstable_batchedUpdates or use middleware to batch.
  4. Storing non-serializable data — functions, Promises, or class instances in store break time-travel and persistence. Keep the store serializable.
  5. Over-normalization — flattening state too aggressively leads to complex joins in selectors. Sometimes a relational structure inside Redux is acceptable.
  6. Ignoring the serializable check middleware — RTK enables it by default in development. Warnings about non-serializable values are easy to dismiss, but they cause silent failures in production when DevTools or persistence middleware tries to stringify the state. Treat those warnings as errors.
  7. Not splitting reducers into slices — a single monolithic reducer becomes a nightmare to maintain. Use combineReducers or configureStore's reducer object from day one.

Here's a real one: I worked with a team that ignored the serializableCheck warnings for weeks. Then they added redux-persist and the app crashed on page reload because functions were being serialized. Those warnings are not suggestions — they're early warning systems for production failures.

pitfallExample.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// BAD: inline function returning new reference
useSelector(state => state.items.filter(item => item.active));

// GOOD: memoized selector
const selectActiveItems = createSelector(
  state => state.items,
  items => items.filter(item => item.active)
);
useSelector(selectActiveItems);

// BAD: storing Date object
// store.dispatch({ type: 'SET_DATE', payload: new Date() });

// GOOD: store timestamp number
store.dispatch({ type: 'SET_DATE', payload: Date.now() });

// BAD: three sequential dispatches causing three re-renders
// dispatch({ type: 'INIT' });
// dispatch({ type: 'PROCESS' });
// dispatch({ type: 'COMPLETE' });

// GOOD: batch into one dispatch
// dispatch({ type: 'BATCHED_UPDATE', payload: { step: 'complete' } });
Try it live
Mental Model
Serializability as Contract
Your state is a contract between the store and every middleware/thunk that touches it. Non-serializable values break that contract.
  • Storing Date objects? Store timestamps (numbers) instead
  • Storing class instances? Extract plain data objects
  • Storing functions? They don't belong in state at all
  • Treat serializableCheck warnings as build-breaking errors in CI
📊 Production Insight
A payment flow dispatched three actions in sequence (init, process, complete) synchronously. Each dispatch caused a full UI re-render, leading to a flash of the wrong UI state for a split second.
Rule: batch synchronous dispatches or use a single action to represent the final state.
Worse case: a team had 10 dispatches in a click handler — the page froze for 2 seconds on every click.
🎯 Key Takeaway
Keep state serializable.
Memoize selectors to avoid unnecessary re-renders.
Batch related dispatches for performance.
Divide reducers into slices early.

Testing Redux in Production

Redux logic is pure — that makes it extremely testable. But teams often skip tests for reducers, thunks, and selectors, assuming they work. That assumption costs hours in production.

Reducer tests: Pure functions. Test that given an initial state and an action, the new state is correct. Use createReducer or createSlice and test the exported reducer directly.

Thunk tests: Use a mock store created with configureStore and mock API calls. Dispatch the thunk and assert that the correct actions were dispatched (pending, fulfilled, rejected). Use createAsyncThunk with a mock API to control timing.

Selector tests: Memoized selectors are also pure. Call them with different state shapes and verify the derived value.

Component integration tests: Use Provider with a real store and render from React Testing Library. Dispatch initial state, then assert the UI renders correctly. For async flows, use waitFor to wait for state updates.

One pattern that fails in production: integration tests that reuse the same mock store across tests. If you don't reset the store between tests, state leaks. Create a fresh store for each test using a setupStore function.

Another nuanced point: when testing thunks that dispatch other thunks, you should test those as integration tests rather than mocking the inner thunk. Otherwise you're testing the mock, not the real flow.

I once saw a test suite that passed locally every time but failed in CI 30% of the time — the culprit was a shared store instance. Switching to per-test stores fixed it completely.

testThunk.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// io.thecodeforge.redux.test
import { configureStore } from '@reduxjs/toolkit';
import { fetchUser } from './userSlice';
import { api } from './api';

// Mock the API module
jest.mock('./api', () => ({
  api: {
    get: jest.fn(),
  },
}));

const setupStore = () =>
  configureStore({
    reducer: {
      user: userReducer,
    },
  });

describe('fetchUser thunk', () => {
  it('dispatches pending and fulfilled actions on success', async () => {
    api.get.mockResolvedValue({ data: { id: 1, name: 'Alice' } });
    const store = setupStore();
    await store.dispatch(fetchUser(1));
    const actions = store.getActions();
    expect(actions[0].type).toEqual(fetchUser.pending.type);
    expect(actions[1].type).toEqual(fetchUser.fulfilled.type);
    expect(actions[1].payload).toEqual({ id: 1, name: 'Alice' });
  });
});
Try it live
⚠ Test Store Leaks
Always create a fresh store per test. Shared store instances cause state leakage between tests, leading to flaky results that pass locally but fail in CI.
📊 Production Insight
A team's CI pipeline consistently failed because the integration test store was created once per file instead of per test. State from a failed test leaked into the next test, causing cascading failures.
Rule: create a fresh configureStore instance inside beforeEach or a setupStore helper.
Bonus: use restore mocks in afterEach to prevent leaked API responses from affecting other tests.
🎯 Key Takeaway
Test reducers and selectors as pure functions.
Use fresh stores for each test.
Mock APIs in thunk tests; assert dispatched actions.
Don't skip integration tests for async flows.
Never share store instances between tests.

Selectors: The Memoization Trap That Sinks Performance

Most devs think useSelector is free. It's not. Every time you call it, React-Redux runs the selector function and compares the result to the previous one using strict equality. If you return a new object or array on every call, you trigger a re-render. This is why you see components flickering when nothing changed. The fix is memoized selectors. Reselect (now bundled into Redux Toolkit's createSelector) caches the last input and output. Only recompute when the input slice changes. But here's the trap: if your selector returns a derived value that's expensive to compute, like filtering a 10K-item list, and you don't memoize, you're recalculating on every state change across the entire app. That's a silent CPU killer. Always use createSelector when deriving data. Never return inline objects or arrays from a selector unless you want a re-render party.

MemoizedSelectorPattern.javascriptJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// io.thecodeforge — javascript tutorial

import { createSelector } from '@reduxjs/toolkit';

// Bad: returns a new array every time
const selectUnpaidInvoicesUnmemoized = (state) =>
  state.invoices.filter(inv => !inv.paid);

// Good: memoized selector, only recomputes when invoices changes
const selectUnpaidInvoices = createSelector(
  [state => state.invoices],
  (invoices) => invoices.filter(inv => !inv.paid)
);

// Usage in component — no unnecessary re-renders
const unpaid = useSelector(selectUnpaidInvoices);

// Edge case: selector with multiple inputs
const selectOverdueAccounts = createSelector(
  [state => state.invoices, state => state.thresholdDays],
  (invoices, threshold) => invoices.filter(
    inv => !inv.paid && daysSinceDue(inv) > threshold
  )
);
Output
Component renders only when the derived array contents actually change, not on every unrelated update
Try it live
⚠ Never Do This:
Using useSelector(state => state.orders.map(orderTransform)) creates a new array every render. Your component re-renders infinitely. Wrap it in createSelector.
🎯 Key Takeaway
If your selector returns a new reference, you pay for re-renders. Memoize or pay the price.

Slice Cohesion: Why Your Reducers Are Bloody Mess

You've seen it: a single reducer handling user auth, shopping cart, and notification preferences. That's a rookie mistake. The entire point of Redux's architecture is separation of concerns via slices. Each slice should own a single domain of your application state. If you find yourself writing a reducer that checks action types from three different domains, you've created a god reducer. It's untestable, unreadable, and will break in production. Redux Toolkit's createSlice enforces this by scoping reducers to a slice name. But the design still depends on you. Keep slices small. One slice per feature or data domain. createSlice auto-generates action creators. Use those. Never dispatch raw action types across slices unless you're handling cross-cutting concerns like auth logout that reset state. When you do need cross-slice logic, use extraReducers to listen to actions from other slices. This keeps reducers pure and predictable.

SliceDesignPattern.javascriptJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// io.thecodeforge — javascript tutorial

import { createSlice } from '@reduxjs/toolkit';

// Profile slice — owns user profile data only
const profileSlice = createSlice({
  name: 'profile',
  initialState: { name: '', email: '', loaded: false },
  reducers: {
    setProfile(state, action) {
      state.name = action.payload.name;
      state.email = action.payload.email;
      state.loaded = true;
    },
  },
});

// Cart slice — owns cart items, nothing else
const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [], total: 0 },
  reducers: {
    addItem(state, action) {
      state.items.push(action.payload);
      state.total += action.payload.price;
    },
    clearCart(state) {
      state.items = [];
      state.total = 0;
    },
  },
  // Cross-slice: clear cart when user logs out
  extraReducers: (builder) => {
    builder.addCase('auth/logout', (state) => {
      state.items = [];
      state.total = 0;
    });
  },
});

export const { setProfile } = profileSlice.actions;
export const { addItem, clearCart } = cartSlice.actions;
Output
Profile and cart slices remain independent, testable, and only listen to their own actions except for the explicit cross-slice logout handler
Try it live
🔥Senior Shortcut:
Rule of thumb: if a reducer file exceeds 100 lines, it's trying to do too much. Split it into slices by feature, not by UI component.
🎯 Key Takeaway
One slice per domain. If two unrelated actions modify the same slice, you're violating separation of concerns.

createEntityAdapter for Normalized State

When dealing with relational data like cart items that reference products, users, or inventory, deeply nested state can lead to performance issues and complex reducer logic. Redux Toolkit's createEntityAdapter provides a normalized state structure out of the box, storing entities in a flat dictionary and maintaining an array of IDs. This eliminates duplication and makes updates, inserts, and removals O(1) operations.

To use it, define an adapter with createEntityAdapter and a selectors object. The adapter generates reducers like addOne, updateOne, removeOne, and setAll. Combine these with your slice's extra reducers to handle async thunks. For example, when fetching cart items from an API, you can use setAll to replace the entire collection. The adapter also provides memoized selectors like selectAll and selectById that work seamlessly with useSelector.

Normalization is especially beneficial when cart items have relationships (e.g., product details). Instead of storing the entire product object inside each cart item, store only the product ID and keep products in a separate slice. This avoids data inconsistency and reduces re-renders because changes to a product don't affect cart item references.

In practice, you'll define your cart slice with createEntityAdapter and use getSelectors to create selectors scoped to that slice. The adapter's getInitialState also allows you to add extra fields like loading or error alongside the normalized entities. This pattern scales well for complex domains and is a recommended practice in production Redux applications.

cartSlice.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit';
import { fetchCartItemsAPI } from './api';

const cartAdapter = createEntityAdapter();

const initialState = cartAdapter.getInitialState({
  loading: false,
  error: null,
});

export const fetchCartItems = createAsyncThunk('cart/fetchItems', async () => {
  const response = await fetchCartItemsAPI();
  return response.data;
});

const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addCartItem: cartAdapter.addOne,
    removeCartItem: cartAdapter.removeOne,
    updateCartItem: cartAdapter.updateOne,
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchCartItems.pending, (state) => { state.loading = true; })
      .addCase(fetchCartItems.fulfilled, (state, action) => {
        cartAdapter.setAll(state, action.payload);
        state.loading = false;
      })
      .addCase(fetchCartItems.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  },
});

export const { selectAll: selectAllCartItems, selectById: selectCartItemById } =
  cartAdapter.getSelectors((state) => state.cart);

export const { addCartItem, removeCartItem, updateCartItem } = cartSlice.actions;
export default cartSlice.reducer;
Try it live
💡Normalization vs Denormalization
📊 Production Insight
In large applications, normalized state reduces the risk of stale data and makes optimistic updates easier. Always use the adapter's generated reducers for CRUD operations to maintain consistency.
🎯 Key Takeaway
Normalize relational state with createEntityAdapter to simplify updates, avoid duplication, and improve performance.

Next.js SSR with Redux

Server-side rendering (SSR) with Next.js and Redux requires careful handling to avoid hydration mismatches and ensure the server-rendered state is transferred to the client. The typical pattern is to create a fresh Redux store for each request on the server, populate it with initial data (e.g., from an API), and then serialize the state into the HTML so the client can rehydrate.

In Next.js, you can use getServerSideProps to dispatch async thunks and wait for them to complete before rendering. The Redux store is created per request using a makeStore function. After dispatching, you extract the state and pass it as a prop to the page component. On the client side, you create a store with that preloaded state using configureStore.

To avoid code duplication, define a wrapper using next-redux-wrapper package, which handles store creation and hydration automatically. It provides a useStore hook and integrates with Next.js's App component. The wrapper ensures that the same store instance is used during SSR and client-side navigation.

One common pitfall is forgetting to serialize non-serializable values (e.g., Dates, functions) in the state. Redux Toolkit's serializableCheck middleware will warn you. Also, ensure that your thunks are idempotent and do not rely on browser-only APIs (like localStorage) during SSR.

For data fetching, prefer createAsyncThunk and dispatch inside getServerSideProps. The wrapper will wait for all promises to resolve before rendering. This pattern works well for SEO-critical pages that need data upfront.

store.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
import { configureStore } from '@reduxjs/toolkit';
import { createWrapper } from 'next-redux-wrapper';
import cartReducer from './cartSlice';

export const makeStore = (preloadedState) =>
  configureStore({
    reducer: { cart: cartReducer },
    preloadedState,
  });

export const wrapper = createWrapper(makeStore, { debug: false });
Try it live
🔥Hydration Mismatch
📊 Production Insight
For production, consider using Next.js's Incremental Static Regeneration (ISR) with Redux for pages that can be pre-rendered. SSR with Redux adds server load, so cache API responses when possible.
🎯 Key Takeaway
Use next-redux-wrapper to create a fresh Redux store per request and hydrate the client seamlessly.
Selector Memoization: createSelector vs Inline Impact on performance and data consistency for cart items createSelector Inline Selector Memoization Caches result until inputs change Recomputes on every render Re-render Trigger Only if derived data changes On any store state change Performance Optimized for large state trees Can cause unnecessary re-renders Bug Risk Low if inputs are stable High: may return stale or empty arrays Use Case Complex derived data like filtered cart Simple direct state access THECODEFORGE.IO
thecodeforge.io
Redux With React

Testing Redux with MSW

Mock Service Worker (MSW) is a powerful tool for testing Redux async thunks by intercepting network requests at the service worker level. Unlike mocking fetch or axios directly, MSW works with any HTTP client and provides realistic responses. This makes integration tests more reliable because they exercise the actual thunk logic without hitting real APIs.

To set up MSW, define handlers that match your API endpoints. In your test file, start the server before all tests and reset handlers after each test. Then, dispatch your async thunk and assert on the final state. For example, test that fetchCartItems correctly sets loading, populates entities, and handles errors.

MSW also supports request validation, response delays, and error scenarios. You can test loading states by delaying responses, and error states by returning a 500 status. This ensures your Redux slices handle all edge cases.

Combine MSW with Redux Toolkit's configureStore to create a test store. Use @testing-library/react to render components and check UI updates. MSW's integration with Jest and Vitest is seamless, and it works in both Node.js and browser environments.

One best practice is to define your MSW handlers in a separate file that mirrors your API routes. This keeps tests DRY and allows reuse across multiple test suites. Also, use setupServer for Node.js tests and setupWorker for browser-based tests.

cart.test.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { configureStore } from '@reduxjs/toolkit';
import cartReducer, { fetchCartItems } from './cartSlice';

const server = setupServer(
  http.get('/api/cart', () => {
    return HttpResponse.json([{ id: 1, name: 'Item 1', quantity: 2 }]);
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('fetchCartItems populates entities', async () => {
  const store = configureStore({ reducer: { cart: cartReducer } });
  await store.dispatch(fetchCartItems());
  const state = store.getState().cart;
  expect(state.entities[1].name).toBe('Item 1');
  expect(state.loading).toBe(false);
});

test('fetchCartItems handles error', async () => {
  server.use(
    http.get('/api/cart', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );
  const store = configureStore({ reducer: { cart: cartReducer } });
  await store.dispatch(fetchCartItems());
  const state = store.getState().cart;
  expect(state.error).toBeDefined();
  expect(state.loading).toBe(false);
});
Try it live
⚠ MSW vs Mocking Fetch
📊 Production Insight
In CI/CD, run MSW-based tests without starting a real server. This reduces flakiness and speeds up test execution. Always clean up handlers between tests to avoid cross-test contamination.
🎯 Key Takeaway
Use MSW to mock API responses in Redux tests for realistic, maintainable integration tests.
● Production incidentPOST-MORTEMseverity: high

The Missing Cart Item: Race Condition in Async Thunks

Symptom
Users reported items randomly vanishing from their cart after adding multiple items quickly. The UI showed the item briefly, then disappeared on the next action without any error.
Assumption
The team assumed Redux thunks are atomic — that dispatching inside a thunk would complete before the next dispatch. They didn't account for pending async operations returning out of order.
Root cause
Two synchronous dispatches to add items triggered two thunks. The first thunk's API call took longer than the second, so the second thunk's dispatch of addItem.fulfilled happened first, then the first thunk's fulfilled overwrote the state with older data, effectively removing the second item.
Fix
Used createAsyncThunk with unique requestId and added a reconciliation step: instead of replacing the entire list, the reducer used an upsert pattern that updated individual items by ID without removing other entries.
Key lesson
  • Never assume async operations complete in order — always design reducers to be idempotent and merge, not replace.
  • Use unique identifiers in action payload to merge results correctly.
  • Consider optimistic updates with rollback for better UX, but ensure the reducer can handle out-of-order responses.
  • Add requestId to each thunk instance and ignore fulfilled actions that are stale based on a sequence counter.
Production debug guideSymptom to action guide for the most common Redux production issues.5 entries
Symptom · 01
UI not updating after dispatch
Fix
Check DevTools: was the action dispatched? If yes, did state change? If state changed but UI didn't, verify useSelector selector returns a new reference and component is subscribed correctly.
Symptom · 02
Component re-rendering too often (performance lag)
Fix
Add a debug log inside the component. If it logs on every dispatch, the selector is returning a new reference each time. Use console.log with the selector result. Fix with createSelector.
Symptom · 03
State mutated unexpectedly (time-travel broken)
Fix
Enable serializableCheck middleware (default in RTK). It logs warnings when non-serializable values are stored. Check any direct state assignment in reducers.
Symptom · 04
Thunk actions never reach reducer (no error)
Fix
Check if the middleware array includes thunk middleware. If you replaced the default middleware, thunk might be missing. Log the middleware chain in configureStore options.
Symptom · 05
Async thunks cause stale data (race condition)
Fix
Add requestId to each thunk. In the reducer, ignore fulfilled actions if the requestId is older than the last seen requestId for that entity. Use an upsert pattern instead of replace.
★ Redux Debug Cheat SheetQuick commands and checks for common Redux issues in development.
Component doesn't re-render after store change
Immediate action
Open Redux DevTools. Verify action was dispatched and state changed. If yes, check component's useSelector return value.
Commands
window.__REDUX_DEVTOOLS_EXTENSION__ getState()
console.log(store.getState())
Fix now
Ensure selector returns a new reference only when actual data changes. Use reselect createSelector.
State mutation error in console+
Immediate action
Identify which reducer caused the mutation. Look for direct property assignment on old state.
Commands
Add serializableCheck: true in configureStore (default). In development, it logs violations.
Use Immer's produce() or RTK createSlice (which uses Immer internally)
Fix now
Replace direct mutations with way of returning new state (spread, Object.assign, or allow Immer).
Multiple sequential API calls causing race conditions+
Immediate action
Check if thunks are using takeLatest or aborting previous requests.
Commands
Add unique requestId to each thunk: createAsyncThunk('type', async (_, {requestId}) => {...})
In reducer: update state based on item ID, not by replacing entire array.
Fix now
Use a merge strategy in the reducer: if adding item {id, data}, update state.entities[id] without clearing others.
Redux DevTools not showing state changes+
Immediate action
Check if store is configured with DevTools enabled (configureStore enables it by default). If using legacy createStore, ensure DevTools enhancer is applied.
Commands
console.log('store state:', store.getState()) to verify state actually changes.
Inspect the store enhancer chain: if using createStore, apply composeWithDevTools.
Fix now
Use configureStore instead of createStore. DevTools is automatically connected.
Integration tests fail due to shared store state+
Immediate action
Check if store is created once per describe block instead of per test.
Commands
Use `beforeEach(() => { store = setupStore(); })`
Ensure each test starts with a clean store by creating it inside the test or in beforeEach
Fix now
Create a setupStore function that returns a fresh configureStore instance
State Management Options
AspectReact Context + useReducerRedux (with RTK)Zustand
BoilerplateLow to mediumMedium (RTK reduces it)Low
DevToolsNo built-inExcellent (time-travel)Basic (optional extension)
Performance with frequent updatesCan cause unnecessary re-renders of all consumersOptimized with useSelector and memoizationFine-grained subscriptions
Ecosystem / MiddlewareNoneThunk, Saga, Observable, RTK QueryMinimal (immer plugin)
Learning curveLowMedium (RTK lowers it)Low
Testing supportManual (no built-in tools)Excellent (pure reducers, mocking, store actions)Good (direct store access)
⚙ Quick Reference
11 commands from this guide
FileCommand / CodePurpose
store.jsconst counterSlice = createSlice({The Redux Store
Counter.jsxfunction Counter() {useSelector
thunkExample.jsexport const fetchUser = createAsyncThunk(Middleware
apiSlice.jsexport const apiSlice = createApi({Redux Toolkit
pitfallExample.jsuseSelector(state => state.items.filter(item => item.active));Common Production Pitfalls with Redux
testThunk.jsjest.mock('./api', () => ({Testing Redux in Production
MemoizedSelectorPattern.javascriptconst selectUnpaidInvoicesUnmemoized = (state) =>Selectors
SliceDesignPattern.javascriptconst profileSlice = createSlice({Slice Cohesion
cartSlice.jsconst cartAdapter = createEntityAdapter();createEntityAdapter for Normalized State
store.jsexport const makeStore = (preloadedState) =>Next.js SSR with Redux
cart.test.jsconst server = setupServer(Testing Redux with MSW

Key takeaways

1
Redux uses a single immutable state tree; actions describe events and reducers produce new states without mutations.
2
useSelector re-renders on reference change; always memoize derived data with createSelector.
3
Middleware intercepts actions for side effects; thunks are simple, sagas are powerful, RTK Query covers most data fetching.
4
ConfigureStore from RTK is the standard; it includes middleware, DevTools, and Immer for safe updates.
5
Keep state serializable; batch related dispatches; avoid deep nesting for performance.
6
Async thunks need idempotent reducers
always merge by ID, never replace entire collections.
7
Test reducers and selectors as pure functions; use fresh store instances per test to avoid state leaks.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does useSelector determine when to re-render the component?
Q02SENIOR
What is the difference between a Redux middleware and a reducer?
Q03JUNIOR
Explain the concept of "immutable update" in Redux and why it's importan...
Q04SENIOR
What are the trade-offs between using Redux Thunk and Redux Saga for sid...
Q05SENIOR
How would you optimize a React-Redux application that has performance is...
Q06SENIOR
What is the role of RTK Query and how does it differ from createAsyncThu...
Q07SENIOR
How do you test a Redux thunk that makes an API call?
Q01 of 07SENIOR

How does useSelector determine when to re-render the component?

ANSWER
useSelector subscribes to the Redux store and runs the selector function on every dispatch. React-Redux compares the new result with the previous result using strict reference equality (===). If the values differ, the component re-renders. If the selector returns a new object each time (like from .map or .filter), the component will re-render on every dispatch even if the underlying data hasn't changed. To avoid this, use memoized selectors with createSelector.
FAQ · 7 QUESTIONS

Frequently Asked Questions

01
Do I always need Redux for React applications?
02
What is the difference between useSelector and connect?
03
Can I use Redux with TypeScript?
04
How do I reset Redux state on logout?
05
What are the main differences between Redux and Zustand?
06
How do I handle optimistic updates with Redux?
07
What are the best practices for structuring Redux reducers?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
July 19, 2026
last updated
2,466
articles · all by Naren
🔥

That's React. Mark it forged?

12 min read · try the examples if you haven't

Previous
React State Management
37 / 40 · React
Next
Zustand — Modern State Management