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.
- 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.
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.
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())Key takeaways
Common mistakes to avoid
6 patternsMutating state directly inside a reducer
Inline function in useSelector creating new references
Storing non-serializable values (functions, class instances, Date objects) in the store
Using a single reducer for the whole app instead of combining slices
Forgetting to handle loading/error states in async thunks
Not resetting store between integration tests
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.js. Mark it forged?
12 min read · try the examples if you haven't