Senior 12 min · March 05, 2026

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
May 24, 2026
last updated
1,554
articles · all by Naren
 ● 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?

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.

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: Async Thunks & Missing Cart Items THECODEFORGE.IO Redux with React: Async Thunks & Missing Cart Items Flow from store to selector, middleware, and common pitfalls Redux Store Single state tree with slices useSelector Hook Subscribes to store slices Async Thunk Middleware Handles side effects like API calls Redux Toolkit Standardized store setup & slices Memoized Selectors Prevent unnecessary re-renders ⚠ Missing cart items from async thunks Ensure thunk dispatches update correct slice and selectors are memoized THECODEFORGE.IO
thecodeforge.io
Redux with React: Async Thunks & Missing Cart Items
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),
});
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);
Selector as a Lens
  • 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

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;
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;
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' } });
Serializability as 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' });
  });
});
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
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
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.
● 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)

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.

Common mistakes to avoid

6 patterns
×

Mutating state directly inside a reducer

Symptom
UI doesn't update, time-travel debugging shows incorrect state history, and dev warnings appear. Silent failures in production.
Fix
Always return a new state object. Use spread operator for shallow copies, or better, use createSlice from Redux Toolkit which uses Immer to allow mutable-looking code that produces immutable state.
×

Inline function in useSelector creating new references

Symptom
Component re-renders on every store dispatch, even when the selected data hasn't changed. Performance degrades linearly with number of components.
Fix
Extract selectors with createSelector from @reduxjs/toolkit. Never use .filter, .map, or object literals inside useSelector without memoization.
×

Storing non-serializable values (functions, class instances, Date objects) in the store

Symptom
Time-travel debugging breaks, middleware may crash, and Redux DevTools fails to serialize state.
Fix
Keep the store serializable: store timestamps as numbers, not Date objects. Store config in a separate module instead of in Redux. Enable serializableCheck middleware (default in RTK).
×

Using a single reducer for the whole app instead of combining slices

Symptom
Reducer becomes a massive switch statement with hundreds of cases, hard to maintain, and accidental state coupling between features.
Fix
Use combineReducers (or configureStore's reducer object) to split logic into feature slices. Each slice manages its own part of the state tree.
×

Forgetting to handle loading/error states in async thunks

Symptom
UI shows stale data or remains in loading state forever after a failed API call. No feedback to the user.
Fix
Use createAsyncThunk with extraReducers to handle pending, fulfilled, and rejected states. Always set loading to 'idle' on error to allow retries.
×

Not resetting store between integration tests

Symptom
Tests pass locally but fail in CI due to state leaks. Flaky, hard-to-reproduce failures.
Fix
Create a fresh store instance for each test using a setupStore function called in beforeEach.
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
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's React.js. Mark it forged?

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

Previous
React Performance Optimisation
11 / 47 · React.js
Next
React Testing with Jest