Advanced 11 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
Plain-English first. Then code. Then the interview question.
About
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.

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.

What is Redux with React?

Redux with React is a core concept in JavaScript. Rather than starting with a dry definition, let's see it in action and understand why it exists. At its heart, Redux is a state management library that decouples state from the UI. The store holds a single JavaScript object representing the entire application state. Components dispatch actions (plain objects with a type field) and pure reducers compute the new state. React-Redux provides the bridge: the Provider component makes the store available, and useSelector() lets components read slices of state while useDispatch() gives them a way to trigger changes.

This pattern eliminates the need for prop drilling and keeps state updates centralized. Every state transition is logged and traceable, which is a game-changer when debugging complex interactions. The real power comes when you combine it with middleware for async flows — but that's a later section.

A common misconception: Redux forces you to put all state in one giant object. That's not true — you can combine multiple reducers via combineReducers or configureStore's reducer object, each managing its own slice. The single store is a single JavaScript object, but the shape is up to you. The rule is: serializable, predictable, and testable.

One thing many tutorials skip: the store doesn't just hold state — it also holds the reducer and middleware chain. When you dispatch, the store orchestrates the entire pipeline. Understanding that flow — dispatch → middleware → reducer → new state → subscribers — is what separates devs who debug quickly from those who guess.

I've seen teams treat Redux like a black box and then spend hours trying to understand why a dispatched action didn't update the UI. Knowing the pipeline makes those investigations ten minutes instead of ten hours.

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.

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.

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.

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.

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.

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.

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

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

Common Mistakes to Avoid

  • 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 Questions on This Topic

  • QHow does useSelector determine when to re-render the component?Mid-levelReveal
    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.
  • QWhat is the difference between a Redux middleware and a reducer?Mid-levelReveal
    A reducer is a pure function that updates the state based on an action. It cannot have side effects or async logic. A middleware intercepts actions before they reach the reducer and can perform side effects (like API calls), dispatch new actions, modify the action, or even block it. Multiple middlewares form a pipeline. Examples: thunk middleware turns functions into async dispatch calls; saga middleware uses generators for complex flows.
  • QExplain the concept of "immutable update" in Redux and why it's important.JuniorReveal
    An immutable update means returning a new state object instead of modifying the existing one. Redux relies on reference equality to detect changes. If you mutate the old state, the reference stays the same, and React-Redux's subscription will not trigger a re-render. Additionally, time-travel debugging records state snapshots; mutations corrupt the history. RTK's createSlice uses Immer internally to allow you to write mutable-looking code, but it produces immutable updates under the hood.
  • QWhat are the trade-offs between using Redux Thunk and Redux Saga for side effects?SeniorReveal
    Thunk is simpler: it adds a small amount of middleware and lets you write async logic inside action creators. It works well for basic API calls. Saga is more powerful but complex: it uses generators to model flows, can cancel, fork, join, and race effects. Sagas are better for complex workflows like real-time connections, polling with cancellations, or handling simultaneous actions. The trade-off is added cognitive overhead and debugging difficulty. For most apps, thunk is sufficient. RTK Query often eliminates both for data fetching.
  • QHow would you optimize a React-Redux application that has performance issues due to excessive re-renders?SeniorReveal
    First, identify which components are re-rendering too often using React DevTools or Redux DevTools' action log. Then check if selectors return new references on every call. Memoize derived data with createSelector. Ensure that components only subscribe to the minimum slice they need. Use React.memo for component memoization. For lists, ensure list items have stable keys. Consider normalizing state to reduce redundant nesting. Finally, batch dispatches that happen together to avoid multiple sequential re-renders.
  • QWhat is the role of RTK Query and how does it differ from createAsyncThunk?SeniorReveal
    RTK Query is a data-fetching library built into RTK that auto-generates hooks for API endpoints. Unlike createAsyncThunk, which requires manual action creators, reducers, and loading state handling, RTK Query manages the entire lifecycle: caching, deduplication, polling, optimistic updates, and cache invalidation via tags. With createAsyncThunk you write more code but get more control. RTK Query reduces boilerplate but may be harder to customize for edge cases. The choice depends on the complexity of your caching needs.
  • QHow do you test a Redux thunk that makes an API call?SeniorReveal
    Create a mock for the API module using jest.mock. Set up a fresh store with configureStore and the actual reducer. Dispatch the async thunk and then assert the list of actions that were dispatched using store.getActions(). This tests the entire thunk lifecycle including pending, fulfilled/rejected states. For integration testing, use a real store in a Provider with React Testing Library, and use waitFor to wait for async state changes.

Frequently Asked Questions

Do I always need Redux for React applications?

No. Redux is best when you have complex state that is shared across many components, frequent updates from multiple sources, or need time-travel debugging. For simple apps, React's built-in useState and useReducer with Context may suffice. Overusing Redux adds unnecessary boilerplate.

What is the difference between useSelector and connect?

connect is the legacy higher-order component API to subscribe to Redux store. useSelector is the modern hook that directly subscribes in functional components. useSelector is simpler and less error-prone. connect is still supported but not recommended for new code.

Can I use Redux with TypeScript?

Yes, Redux Toolkit has excellent TypeScript support. Use createSlice with typed initial state and reducers, and use useTypedSelector and useTypedDispatch hooks built on your store's types. RTK provides createAsyncThunk with typed return values and rejection types.

How do I reset Redux state on logout?

Create a root reducer that listens for a logout action. When dispatched, combineReducers will return the initial state for all slices. Use undefined as the state in the root reducer to trigger initial state. Example: const rootReducer = (state, action) => { if (action.type === 'USER_LOGOUT') state = undefined; return appReducer(state, action); }

What are the main differences between Redux and Zustand?

Zustand is a minimal state management library with a simpler API — no actions or reducers, just a store with get and set. It has fine-grained subscriptions by default, less boilerplate, and no built-in middleware (but supports plugins). Redux with RTK is more opinionated, has DevTools, middleware ecosystem, and a standardized pattern that scales better for large teams. For small to medium apps, Zustand can be faster to adopt. For apps requiring strict patterns and debugging, Redux is still the standard.

How do I handle optimistic updates with Redux?

Optimistic updates update the UI before the server responds. In Redux, dispatch the optimistic action immediately, then either revert on error or confirm on success. With RTK Query, use optimisticUpdates in the mutation definition. For manual thunks, dispatch the optimistic state, then on error dispatch a revert action that restores the previous state saved in a temporary variable.

What are the best practices for structuring Redux reducers?

Use createSlice from RTK to group related state, actions, and reducers into a slice. Keep each slice focused on one feature domain. Avoid deeply nested state — normalise when possible but don't over-flatten. Use configureStore's reducer object to compose slices. Always handle loading and error states explicitly.

🔥

That's React.js. Mark it forged?

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

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