Redux with React: Missing Cart Items from Async Thunks
Items vanish from cart when async thunks complete out of order.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- 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.
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
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.
getDefaultMiddleware().concat(myMiddleware).
Otherwise you lose thunk and DevTools serialization checks.concat() at the end - order doesn't matter for loggingmiddleware option - spread defaults firstuseSelector: 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.
- 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
useSelector(state => state.users.map(u => u.name)).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.
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.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.
providesTags on queries and invalidatesTags on mutations. This tells RTK Query exactly when to refetch.devTools: false in the options. Always check your config.onQueryStarted with optimistic updates - use itprovidesTags and invalidatesTagsCommon Production Pitfalls with Redux
Even with tooling, teams hit recurring issues. Here are the patterns that cause the most outages:
- State mutation inside reducers — even with spread operators, deep nested updates are error-prone. Solution: use createSlice (Immer) or manual deep cloning.
- 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.
- Dispatching too many actions synchronously — each dispatch triggers a store update and re-render of all subscribers. Batch related dispatches with
unstable_batchedUpdatesor use middleware to batch. - Storing non-serializable data — functions, Promises, or class instances in store break time-travel and persistence. Keep the store serializable.
- Over-normalization — flattening state too aggressively leads to complex joins in selectors. Sometimes a relational structure inside Redux is acceptable.
- 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.
- Not splitting reducers into slices — a single monolithic reducer becomes a nightmare to maintain. Use
combineReducersorconfigureStore'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.
- 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
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.
configureStore instance inside beforeEach or a setupStore helper.restore mocks in afterEach to prevent leaked API responses from affecting other 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.
useSelector(state => state.orders.map(orderTransform)) creates a new array every render. Your component re-renders infinitely. Wrap it in createSelector.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.
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.
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.
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.
The Missing Cart Item: Race Condition in Async Thunks
addItem.fulfilled happened first, then the first thunk's fulfilled overwrote the state with older data, effectively removing the second item.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.- 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.
console.log with the selector result. Fix with createSelector.window.__REDUX_DEVTOOLS_EXTENSION__ getState()console.log(store.getState())| File | Command / Code | Purpose |
|---|---|---|
| store.js | const counterSlice = createSlice({ | The Redux Store |
| Counter.jsx | function Counter() { | useSelector |
| thunkExample.js | export const fetchUser = createAsyncThunk( | Middleware |
| apiSlice.js | export const apiSlice = createApi({ | Redux Toolkit |
| pitfallExample.js | useSelector(state => state.items.filter(item => item.active)); | Common Production Pitfalls with Redux |
| testThunk.js | jest.mock('./api', () => ({ | Testing Redux in Production |
| MemoizedSelectorPattern.javascript | const selectUnpaidInvoicesUnmemoized = (state) => | Selectors |
| SliceDesignPattern.javascript | const profileSlice = createSlice({ | Slice Cohesion |
| cartSlice.js | const cartAdapter = createEntityAdapter(); | createEntityAdapter for Normalized State |
| store.js | export const makeStore = (preloadedState) => | Next.js SSR with Redux |
| cart.test.js | const server = setupServer( | Testing Redux with MSW |
Key takeaways
Interview Questions on This Topic
How does useSelector determine when to re-render the component?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's React. Mark it forged?
12 min read · try the examples if you haven't