Sharing state without prop drilling: the Context API and its re-render rules, Context with useReducer, then Redux from the ground up — classic Redux and connect, Redux Thunk explained slowly, Redux Toolkit 2 with createSlice, createAsyncThunk and RTK Query — plus Redux Saga, Zustand 5, and how to choose.
13 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.
React data flows down through props. That's great until a value is needed many levels down: the signed-in user is read by the nav bar, the avatar, the settings page and the checkout button, and every component in between has to accept a user prop just to pass it on. That's prop drilling.
The Context API solves it: a provider near the top makes a value available, and any component below can read it directly with useContext — the components in between don't mention it. From my notes: "Context API allows you to share data (state and functions) globally without prop drilling." The mental model: prop drilling is passing a parcel hand to hand through a line of people who don't care what's in it; context is a pneumatic tube from the mailroom straight to the desk that needs it.
The hook itself — default values, nearest provider wins, use(Context), the React 18 .Provider syntax — is in useContext. This topic is about the pattern you'll write in real apps.
First, the problem:
PropDrilling.jsx
functionApp(){const[user, setUser]=useState(null);return<Layoutuser={user}onLogin={setUser}/>;}functionLayout({ user, onLogin }){return(<divclassName="layout"><NavBaruser={user}onLogin={onLogin}/>{/* Layout doesn't use them, just forwards */}</div>);}functionNavBar({ user, onLogin }){return user ?<nav>Hi, {user.name}</nav>:<buttononClick={()=>onLogin({name:"Rohit"})}>Log in</button>;}
What’s happening
user lives in App, but only NavBar reads it.
Layout has to accept user and onLogin and pass them on, even though it never uses them. With five levels, that's five components whose props exist only for forwarding.
Every new consumer means threading props through more components, and renaming the prop means editing every one of them.
This is fine for two levels — explicit props are easy to follow. It becomes painful when the value is truly app-wide.
The context version, with the pattern my notes recommend — "Use Custom Hooks for Context Access":
auth.jsx
import{ createContext, useContext, useState, useMemo }from"react";const AuthContext =createContext(null);exportfunctionAuthProvider({ children }){const[user, setUser]=useState(null);const value =useMemo(()=>({
user,login:(name)=>setUser({ name }),logout:()=>setUser(null),}),[user]);return<AuthContextvalue={value}>{children}</AuthContext>;}exportfunctionuseAuth(){const ctx =useContext(AuthContext);if(ctx ===null){thrownewError("useAuth must be used inside <AuthProvider>");}return ctx;}// Deep in the tree — no props passed down to get hereexportfunctionNavBar(){const{ user, login, logout }=useAuth();return user ?(<nav>
Hi, {user.name}<buttononClick={logout}>Log out</button></nav>):(<nav><buttononClick={()=>login("Rohit")}>Log in</button></nav>);}exportfunctionLayout(){return(<divclassName="layout"><NavBar/></div>);}exportfunctionApp(){return(<AuthProvider><Layout/></AuthProvider>);}
What’s happening
AuthProvider owns the state with an ordinary useState. Context doesn't store anything — it's only the transport; the state lives in the provider component.
It packages the state and the actions that change it into one value object, memoised with useMemo([user]) so the object only changes when user does (why that matters is the next topic).
useAuth() is the only way the rest of the app touches the context. The context object itself isn't exported, so nobody can bypass the hook.
createContext(null) plus the null check turns a common mistake — rendering a consumer outside its provider — into a clear error. Rendering <NavBar /> alone in the test threw "useAuth must be used inside <AuthProvider>" instead of a confusing "cannot read properties of null".
Layout no longer mentions user at all. Clicking "Log in" calls login("Rohit"), the provider's state changes, the value changes, and NavBar re-renders with "Hi, Rohit". "Log out" goes back. Both were checked in the sandbox.
What my notes said context is for, which still holds: "themes, authentication, or simple global states". Typical providers: theme, auth/session, locale, feature flags, a toast/notification system, and dependency injection of services (an API client, an analytics object) that rarely change.
The one rule that drives everything here: when a provider's value changes, every component that reads that context re-renders — and "changes" means Object.is(oldValue, newValue) is false. An object literal written in JSX is a new object on every render, so it counts as a change every time.
My notes listed four best practices; each one follows from that rule:
Avoid overusing context. "It's not a replacement for state management libraries like Redux. Use context for things like themes, authentication, or simple global states, but don't overuse it for deeply nested, complex state trees."
Avoid frequent updates. "If context values change frequently, it may lead to unnecessary re-renders across the component tree. Use memoization or split context providers if you want to avoid this."
Split contexts for different concerns — a ThemeContext and an AuthContext instead of one AppContext.
Use custom hooks for context access — useAuth() instead of useContext(AuthContext) everywhere (shown in The Context API).
Here's rule 2 measured. The provider has an unrelated piece of state (a ticking counter), and passes an inline object:
Settings.jsx
import{ createContext, useContext, useState, useMemo }from"react";const SettingsContext =createContext(null);// ❌ A new object every renderexportfunctionSettingsProviderBad({ children }){const[theme, setTheme]=useState("light");const[tick, setTick]=useState(0);// unrelated state, e.g. a clockreturn(<SettingsContextvalue={{ theme, setTheme }}><buttononClick={()=>setTick((t)=> t +1)}>tick {tick}</button>{children}</SettingsContext>);}// ✅ The same object until theme changesexportfunctionSettingsProviderGood({ children }){const[theme, setTheme]=useState("light");const[tick, setTick]=useState(0);const value =useMemo(()=>({ theme, setTheme }),[theme]);return(<SettingsContextvalue={value}><buttononClick={()=>setTick((t)=> t +1)}>tick {tick}</button>{children}</SettingsContext>);}exportfunctionThemeBadge(){const{ theme }=useContext(SettingsContext);return<span>theme: {theme}</span>;}// <SettingsProviderBad><ThemeBadge /></SettingsProviderBad>, click "tick" 3 times → ThemeBadge rendered 4 times// <SettingsProviderGood><ThemeBadge /></SettingsProviderGood>, click "tick" 3 times → ThemeBadge rendered 1 time
What’s happening
ThemeBadge is passed as children, so it's created by the parent of the provider. When the provider re-renders because of tick, React reuses the same children element and would normally not re-render ThemeBadge.
In the bad version, each tick re-renders the provider, which writes { theme, setTheme } again — a brand-new object. Object.is(old, new) is false, so React re-renders every consumer: 1 mount + 3 ticks = 4 renders, although theme never changed.
In the good version, useMemo([theme]) returns the same object while theme is unchanged. The context value is identical, so ThemeBadge isn't touched: 1 render.
setTheme is safe to put in the object without a dependency: useState setters never change identity.
Rule of thumb: if a provider's value is an object or array, wrap it in useMemo (and functions in it in useCallback if they're created inline with dependencies). With the React Compiler enabled this memoisation is added automatically.
React.memo doesn't protect a component from context:
React
constC=createContext(0);const Child =memo(functionChild(){return<i>{useContext(C)}</i>;});functionRoot(){const[v, setV]=useState(0);return(<Cvalue={v}><buttononClick={()=>setV(v +1)}>+</button><Child/></C>);}// One click on "+" → Child rendered 2 times (mount + the context change)
What’s happening
Child is wrapped in memo and receives no props, so its props never change.
Clicking "+" changes the provider value from 0 to 1.
memo only compares props. Context changes are delivered to consumers directly, skipping that check, so Child re-renders anyway: 2 renders.
This is by design — a consumer must show the new value. The only ways to avoid it are to not read that context in the component, or to split the context so it reads one that didn't change.
Splitting comes in two flavours:
By concern:ThemeContext, AuthContext, CartContext. A theme toggle re-renders theme consumers only, not everything that touches the user.
By how often it changes: put the changing state in one context and the stable actions (setters, dispatch) in another. Components that only trigger changes — buttons, forms — read the actions context and never re-render when the state changes. Context with useReducer shows this with measured render counts.
AdvancedWhy context has no selectors, and what to do about it
useSelector in Redux lets a component subscribe to one piece of the store and re-render only when that piece changes. Context has no such thing: a consumer gets the whole value and re-renders when any part of it changes. If a CartContext holds { items, total, coupon }, a component that shows only coupon still re-renders when an item is added.
Options, in the order I'd try them:
Split the context further (one for items, one for coupon).
The use-context-selector library emulates selectors on top of context, but at that point a store is usually simpler.
Also don't over-correct: a context that changes when the user logs in or switches theme re-renders the app a handful of times per session. That's fine. The problem is context holding values that change many times per second — form keystrokes, mouse position, timers.
Combining the two is a natural step: useReducer decides how state changes, context delivers the state and dispatch to whoever needs them. It's a small, dependency-free version of the Redux pattern, good for a feature's worth of shared state (a multi-step form, a cart, a dashboard's filters).
From my notes: "When used together, Context supplies the global state to multiple components, while useReducer manages the state updates in a more structured way using a reducer function." My notes' version looked like this:
CounterContext.jsx
// From my notes (React 18 style)const CounterContext =createContext();constCounterProvider=({ children })=>{const[state, dispatch]=useReducer(counterReducer, initialState);return(<CounterContext.Providervalue={{ state, dispatch }}>{children}</CounterContext.Provider>);};constCounterDisplay=()=>{const{ state }=useCounter();return<h1>Count: {state.count}</h1>;};
What’s happening
The reducer (increment, decrement, reset, and a default that throws "Unknown action") is the same shape as in useReducer and is fine as written.
useCounter() is used by the consumers but never defined in the notes — it would be () => useContext(CounterContext), ideally with a "must be inside the provider" check.
value={{ state, dispatch }} creates a new object on every render of the provider. Since state changes on every dispatch anyway, the bigger problem is that components which only need dispatch (the buttons) re-render on every count change too.
<CounterContext.Provider> is the React 18 spelling; React 19 lets you write <CounterContext value>.
My notes then asked the right question: "i forgot and asked chatgpt… why we did this… we could just include state like below…" — a useState in the provider and value={{ count, setCount }}. And answered it: "think about scaling… instead of dispatch you have setState(counter+1) and setState(counter-1) etc… throughout your application… Not a good way right". That's exactly right, and it's worth spelling out why:
The logic lives in one place. With setCount, every component that changes the count contains its own "how" (count + 1, count - 1, 0). With dispatch({ type: "increment" }), components only say what happened and the reducer owns the rules. Change the rule (cap at 10, log analytics) and you edit one function.
Actions are a vocabulary."itemAdded", "couponApplied", "checkoutStarted" read like a log of what the user did — great for debugging and for the reducer's tests.
dispatch never changes identity, so components that only send actions can avoid re-rendering entirely (below). A setState passed with the state in one object doesn't give you that by itself.
It migrates. A reducer + actions moves into a Redux slice almost unchanged when the state outgrows context.
Here's the version I'd write now — two contexts, so state readers and action senders are separate:
CounterContext.jsx
import{ createContext, useContext, useReducer }from"react";const initialState ={count:0};functioncounterReducer(state, action){switch(action.type){case"increment":return{count: state.count +1};case"decrement":return{count: state.count -1};case"reset":return initialState;default:thrownewError(`Unknown action: ${action.type}`);}}const CountContext =createContext(null);const DispatchContext =createContext(null);exportfunctionCounterProvider({ children }){const[state, dispatch]=useReducer(counterReducer, initialState);return(<CountContextvalue={state}><DispatchContextvalue={dispatch}>{children}</DispatchContext></CountContext>);}exportfunctionuseCount(){const state =useContext(CountContext);if(state ===null)thrownewError("useCount must be used inside <CounterProvider>");return state;}exportfunctionuseCounterDispatch(){const dispatch =useContext(DispatchContext);if(dispatch ===null)thrownewError("useCounterDispatch must be used inside <CounterProvider>");return dispatch;}
What’s happening
The provider runs useReducer once and publishes its two results through two contexts: state through CountContext, dispatch through DispatchContext.
state is a new object after every action ({ count: 1 }, { count: 2 }…), so CountContext's value changes on each dispatch — that's intended; readers must update.
dispatch is the same function forever (see useReducer), so DispatchContext's value never changes after mount.
No useMemo is needed: each context value is either the reducer's state (already a new object only when something changed) or the stable dispatch.
The two hooks hide the contexts and fail loudly outside the provider, as in my notes' fourth best practice.
Mount: both components render once. CounterDisplay shows "Count: 0".
Increment: the reducer returns { count: 1 }. CountContext's value changed, so CounterDisplay re-renders. DispatchContext's value didn't change, and CounterControls gets no new props, so it's skipped.
After Increment, Increment, Decrement the display shows "Count: 1" and has rendered 4 times (mount + 3); the controls rendered once, at mount. Reset then brought the display back to "Count: 0".
With my notes' single { state, dispatch } context, the controls would have rendered 4 times too. In a real app the "controls" are often big forms or toolbars, so the split matters.
This is the same shape as Redux — reducer, actions, a provider at the top, hooks to read and dispatch. What's missing compared with Redux is per-component selectors, middleware and DevTools.
Redux's rules (one store, plain-object actions, pure reducers, no dispatching from inside a reducer) can feel like arbitrary ceremony. They make sense once you know what they replaced. The short history: MVC organised UI code for decades; in large browser apps its two-way updates between models and views produced chains of updates nobody could follow; Facebook answered with Flux, a strictly one-way loop; and Redux (2015) kept Flux's loop while cutting it down to a single store and pure functions.
The mental model is traffic. MVC with two-way updates is a city of two-way streets with no lights: any car can go anywhere, and one change can trigger a jam three blocks away. Flux is a one-way ring road with a single on-ramp (the dispatcher): every change enters at the same point, goes round in one direction, and only one car is let on at a time.
MVC
Model-View-Controller was described by Trygve Reenskaug at Xerox PARC in 1978–79, for Smalltalk-79, and spread through Smalltalk-80. The model holds the data and rules, the view displays the model, and the controller turns user input into changes on the model. Views observe models, so when a model changes, every view showing it updates.
Server-side web frameworks (Rails, Django, Spring MVC) adopted the name, and so did the first generation of rich browser apps (Backbone, AngularJS, Ember). In those browser apps the pattern often grew two-way links: a model change updates a view, a view edit writes back into a model through data binding, and models observe other models. Each link is reasonable alone. Together they form a graph where a single change can cascade: model A updates view B, whose binding updates model C, which updates view D and, through an observer, model A again. The order of those updates depends on who subscribed first, and reading the code doesn't tell you what will happen.
At F8 in 2014 ("Hacker Way: Rethinking Web App Development at Facebook"), Facebook engineers told the story of the unread-messages badge: the counter in the top bar would show unread chat messages when there weren't any, the bug was fixed, and it kept coming back. They put that down to the count being kept in sync through chains of model and view updates that no one could trace, and presented Flux as their answer. People pointed out at the time that the "MVC" in the talk's diagram was really a tangle of two-way bindings rather than MVC as Reenskaug described it. The lesson holds either way: when data can flow in both directions between many objects, you can't predict the order of updates.
Flux: a one-way loop
Flux has four parts, and data only goes one way round:
text
┌────────────────────────────────────────────────┐
▼ │
Action ──▶ Dispatcher ──▶ Stores ──▶ Views ──(user)──┘
{type, …} (one per app) (state + (render from calls an action creator
logic) store data)
Actions are plain objects describing what happened: { type: "message/received" }. Small helper functions called action creators build and dispatch them.
The dispatcher is the single hub. Every action goes through it, and it hands every action to every registered store. It holds no data.
Stores hold the state and the logic for changing it. A store updates itself in response to actions, then emits a "changed" event. Nothing outside a store can set its data. waitFor lets one store wait until another store has handled the same action.
Views (React components) read from stores, re-render on "changed", and never modify a store: they dispatch actions.
The key rule: dispatch is synchronous, and you cannot dispatch while a dispatch is in progress. Every store finishes handling one action before the next action can start, so a single action can't set off a hidden chain of further actions. Here's a dispatcher in a few lines of plain JavaScript:
mini-flux.js
// The dispatcher: one per app. It knows nothing about the data, only who to call.classDispatcher{
#callbacks =[];
#isDispatching =false;register(callback){this.#callbacks.push(callback);}dispatch(action){if(this.#isDispatching){thrownewError("Cannot dispatch in the middle of a dispatch.");}this.#isDispatching =true;try{for(const callback ofthis.#callbacks)callback(action);// every store sees every action}finally{this.#isDispatching =false;}}}const dispatcher =newDispatcher();// A store: owns its data and logic, changes only in response to actions, then emits "changed".const unreadStore ={count:0,listeners:newSet(),handle(action){if(action.type ==="message/received")this.count++;elseif(action.type ==="thread/opened")this.count =0;elsereturn;// not for this store: no change eventthis.listeners.forEach((listener)=>listener());},};
dispatcher.register((action)=> unreadStore.handle(action));// A view: reads the store, re-renders on change, and only ever talks back by dispatching.
unreadStore.listeners.add(()=> console.log(` view: badge shows ${unreadStore.count}`));constopenThread=()=> dispatcher.dispatch({type:"thread/opened"});// an action creator
console.log("two messages arrive");
dispatcher.dispatch({type:"message/received"});
dispatcher.dispatch({type:"message/received"});
console.log("user opens the thread");openThread();
console.log("dispatch has returned; count is", unreadStore.count);// A second store that tries to trigger another action while the first is still being handled
dispatcher.register((action)=>{if(action.type ==="message/received") dispatcher.dispatch({type:"sound/played"});});
console.log("a third message arrives");try{
dispatcher.dispatch({type:"message/received"});}catch(error){
console.log(" error:", error.message);}// two messages arrive// view: badge shows 1// view: badge shows 2// user opens the thread// view: badge shows 0// dispatch has returned; count is 0// a third message arrives// view: badge shows 1// error: Cannot dispatch in the middle of a dispatch.
What’s happening
Each dispatch({ type: "message/received" }) sets #isDispatching = true and calls every registered callback in order. The only one so far is unreadStore.handle, which takes count from 0 → 1, then on the second action 1 → 2, and each time tells its listeners, so the "view" prints badge shows 1 and badge shows 2.
The view never touches count. When the user opens the thread, it calls the action creator openThread(), which dispatches thread/opened; the store resets count to 0 and the view prints badge shows 0. Only the store changes its data, and only in response to an action.
"dispatch has returned; count is 0" prints straight after: dispatch is synchronous. By the time dispatch returns, every store has handled the action and every view has been told. There's no queue and no later tick.
Then a second store registers, and on message/received it tries to dispatch sound/played from inside its handler. The first callback runs (count becomes 1, the badge shows 1), then the second calls dispatch while #isDispatching is still true, and the guard throws Cannot dispatch in the middle of a dispatch..
That guard is the whole point. In the MVC tangle, "update this and let it update that" was how cascades happened. Flux forbids it: if handling an action means something else should happen, the store updates its own state for that same action (other stores can react to message/received too), or a new action is dispatched later from a view or an async callback.
The finally resets the flag even after the error, so the next dispatch works. Facebook's real flux package (4.0.4, now archived) throws the same thing as Invariant Violation: Dispatch.dispatch(...): Cannot dispatch in the middle of a dispatch. when the same second store is registered with its Dispatcher.
How Redux simplified Flux
Dan Abramov and Andrew Clark released Redux in 2015, keeping Flux's one-way loop and the plain-object actions and removing the rest of the moving parts, with ideas borrowed from the Elm architecture:
One store instead of many. The whole app's state is one tree, so there's no "which store handles this" and no waitFor ordering between stores. Feature reducers are combined with combineReducers instead.
Reducers instead of stores with logic. A Flux store mutates its own data and emits events. A Redux reducer is a pure function(state, action) => newState that returns a new object. That makes it trivial to test, and it's what lets DevTools record and replay every state (time travel).
No separate dispatcher.store.dispatchis the dispatcher. Middleware (thunks, sagas, listeners) is where async work and side effects go.
The same no-cascade rule. Redux keeps Flux's guard in its own form. Dispatching from inside a reducer, with the plain Redux 5 store from the next topic:
redux-no-dispatch.mjs
import{ legacy_createStore as createStore }from"redux";let store;functionreducer(state ={unread:0}, action){if(action.type ==="message/received"){
store.dispatch({type:"sound/played"});// ❌ side effect inside a reducerreturn{unread: state.unread +1};}return state;}
store =createStore(reducer);try{
store.dispatch({type:"message/received"});}catch(error){
console.log("Error:", error.message);}// Error: Reducers may not dispatch actions.
What’s happening
store.dispatch({ type: "message/received" }) marks the store as dispatching and calls the reducer.
The reducer tries to dispatch sound/played. Redux sees that it's already inside a reducer call and throws Reducers may not dispatch actions., the same rule as Flux's "Cannot dispatch in the middle of a dispatch".
The fix is the same as in Flux: put follow-up work in middleware or a listener (Redux Thunk, RTK's listener middleware), which runs after the reducer has returned, or let another slice's reducer respond to message/received itself.
MVC (in rich front-end apps)
Flux (2014)
Redux (2015)
Data flow
Often two-way: models ↔ views via bindings and observers
One-way: action → dispatcher → stores → views
One-way: action → reducer → store → views
Where state lives
Many models
Several stores, each with its own logic
One store; state is a single tree
How state changes
Anyone holding a model can set it
A store mutates itself when it handles an action
A pure reducer returns a new state
Central hub
None
The dispatcher
store.dispatch
Ordering between parts
Subscription order
waitFor
combineReducers: every slice reducer sees every action
Cascading updates
Possible and common
Forbidden: "Cannot dispatch in the middle of a dispatch"
Forbidden: "Reducers may not dispatch actions"
Debugging
Trace observers by hand
Log every action at the dispatcher
Log every action and state; time travel in DevTools
Flux the library is history now (the repository is archived and its README points to Redux, MobX, Zustand and others), but its one-way rule is everywhere: Redux, Vue's Pinia, NgRx, and React's own useReducer are all "describe what happened, let one place compute the new state".
Redux is a small library for keeping application state in one store outside React, changed only through actions handled by pure reducer functions. From my notes: "React Redux is a powerful state management library that provides a way to manage application state in a predictable manner, especially in large applications. It is built on top of Redux, which is a standalone JavaScript library…" — so two packages: redux (framework-agnostic store) and react-redux (the bindings: Provider, useSelector, useDispatch, connect). Today you also use @reduxjs/toolkit, which wraps redux.
My notes' key concepts, which are still the vocabulary interviewers expect:
Concept
What it is
Store
The object holding the whole state tree. getState(), dispatch(action), subscribe(listener).
Action
A plain object describing what happened: { type: "todos/added", payload: … }. type is required.
Reducer
A pure function (state, action) => newState. Never mutates, never does side effects.
Dispatch
The only way to change state: store.dispatch(action) runs the reducer and notifies subscribers.
Middleware
Functions that sit between dispatch and the reducer: logging, async work (thunk, saga), analytics.
Selector
A function that reads a piece of state: state => state.todos.items. Keeps components unaware of the state's shape.
The three principles behind them: a single source of truth (one store), state is read-only (you never assign to it; you dispatch), and changes are made with pure functions (reducers). The mental model: the store is a bank ledger behind a single counter. Nobody edits the ledger; you hand a slip (action) to the teller (reducer), who writes the new balance. Because every change is a slip, you can replay the slips — that's how Redux DevTools does time-travel debugging.
Correcting my notes: "You can only have one store in a Redux application" — it's the strong convention (and what react-redux's Provider assumes), not a technical limit; createStore can be called as many times as you like. Use one store.
Here's the bare store, no React, with a logging middleware so you can watch the flow:
store-basics.js
import{ legacy_createStore as createStore, applyMiddleware }from"redux";// Reducer: (state, action) => newStatefunctioncounter(state ={value:0}, action){switch(action.type){case"counter/incremented":return{value: state.value +1};case"counter/added":return{value: state.value + action.payload };default:return state;// every reducer sees every action: ignore the rest}}// Middleware: sees every action before the reducer doesconstlogger=(store)=>(next)=>(action)=>{
console.log("dispatching", action.type,"| before:",JSON.stringify(store.getState()));const result =next(action);
console.log(" after:",JSON.stringify(store.getState()));return result;};const store =createStore(counter,applyMiddleware(logger));const unsubscribe = store.subscribe(()=> console.log("subscriber sees", store.getState().value));
store.dispatch({type:"counter/incremented"});
store.dispatch({type:"counter/added",payload:5});unsubscribe();
store.dispatch({type:"something/else"});constselectValue=(state)=> state.value;
console.log("final",selectValue(store.getState()));// dispatching counter/incremented | before: {"value":0}// subscriber sees 1// after: {"value":1}// dispatching counter/added | before: {"value":1}// subscriber sees 6// after: {"value":6}// dispatching something/else | before: {"value":6}// after: {"value":6}// final 6
What’s happening
createStore calls the reducer once with state = undefined and an internal init action, so the default parameter { value: 0 } becomes the initial state.
dispatch({ type: "counter/incremented" }) goes through the middleware first. logger logs "before: {"value":0}", then calls next(action) — which hands the action to the real dispatch.
The real dispatch runs the reducer (0 → 1) and immediately notifies subscribers, which is why "subscriber sees 1" prints before the logger's "after" line: the subscriber runs inside next().
counter/added carries data in payload: 1 + 5 = 6. After unsubscribe(), the third dispatch logs through the middleware but no subscriber prints — and since the reducer's default returns state unchanged, the value stays 6.
selectValue is a selector: components and tests read through it, so if the state shape changes, only the selector changes. react-redux's useSelector is "call a selector on the store and re-render when its result changes".
Actions must be plain objects — remember this rule, because it's the whole reason Redux Thunk exists:
JavaScript
store.dispatch(()=>{});// Error: Actions must be plain objects. Instead, the actual type was: 'function'.// You may need to add middleware to your store setup to handle dispatching other values,// such as 'redux-thunk' to handle dispatching functions.
store.dispatch({payload:1});// Error: Actions may not have an undefined "type" property.// You may have misspelled an action type string constant.
What’s happening
The plain store's dispatch checks its argument before calling the reducer. A function isn't a plain object, so it throws.
The message literally suggests the fix: add middleware such as redux-thunk to handle dispatching functions. Middleware gets the action before this check.
An object without type is also rejected — usually a typo in an imported constant (ADD_TOD), which makes typeundefined.
These checks keep the reducer's world simple: it only ever sees serialisable objects with a type, which is what makes logging, replaying and DevTools possible.
AdvancedRedux in 15 lines
The core really is this small (without middleware and checks):
JavaScript
functionmyCreateStore(reducer){let state =reducer(undefined,{type:"@@INIT"});const listeners =newSet();return{getState:()=> state,dispatch(action){
state =reducer(state, action);
listeners.forEach((l)=>l());return action;},subscribe(l){
listeners.add(l);return()=> listeners.delete(l);},};}const store =myCreateStore((s =0, a)=>(a.type ==="inc"? s +1: s));
store.subscribe(()=> console.log("mini:", store.getState()));
store.dispatch({type:"inc"});// mini: 1
store.dispatch({type:"inc"});// mini: 2
What’s happening
The initial state comes from calling the reducer with undefined — the same trick the real createStore uses, which is why reducers have a default parameter.
dispatch replaces state with whatever the reducer returns, then calls every listener. Nothing else.
subscribe returns an unsubscribe function — exactly the shape useSyncExternalStore wants, which is how react-redux connects the two.
Everything else in Redux — middleware, DevTools, combineReducers — is layered on top of these three functions.
This is how Redux was written from 2015 until Redux Toolkit became the official recommendation (2019–2020): hand-written action type constants, action creators, switch reducers, combineReducers, createStore, and connect with mapStateToProps/mapDispatchToProps, later useSelector/useDispatch. You'll meet it in older codebases and in interviews ("explain mapStateToProps"), so here it is end to end — labelled as the older style. The modern way is the next three topics.
My notes listed the four steps: define actions, create the reducer, set up the store, integrate with React.
Step 1 — actions:
actions.js
// Action typesexportconstADD_TODO="ADD_TODO";exportconstTOGGLE_TODO="TOGGLE_TODO";exportconstREMOVE_TODO="REMOVE_TODO";// Action creatorslet nextId =1;exportconstaddTodo=(text)=>({type:ADD_TODO,payload:{id: nextId++, text }});exportconsttoggleTodo=(id)=>({type:TOGGLE_TODO,payload: id });exportconstremoveTodo=(id)=>({type:REMOVE_TODO,payload: id });
What’s happening
Type constants exist so a typo is a "not defined" error at import time instead of an action that silently matches nothing.
Action creators are functions that build the action object, so components call addTodo("Milk") instead of writing { type: ADD_TODO, payload: … } by hand.
Correcting my notes: the notes created the ID inside the reducer with id: Date.now(). That makes the reducer impure (same input, different output) and gives two todos added in the same millisecond the same ID. Generating the ID in the action creator keeps the reducer pure and testable.
Each todo action needs three edits across the codebase — a constant, a creator, a reducer case. That boilerplate is what Redux Toolkit removes.
Step 2 — the reducers, combined into one root reducer:
Each reducer manages one key of the state. todos only ever sees and returns the array; filter only the string.
Every update is immutable: spread to add, map with a copied object to toggle, filter to remove. Returning a mutated array would leave the reference unchanged, and useSelector/connect would not re-render.
combineReducers({ todos, filter }) builds a root reducer that calls each child reducer with its slice and assembles { todos, filter }. Every reducer receives every action, which is why default: return state is essential here.
The filter reducer shows that not every reducer needs a switch — it's just a function.
Step 3 — the store, and step 4 — giving it to React:
main.jsx
import{ legacy_createStore as createStore }from"redux";import{ createRoot }from"react-dom/client";import{ Provider }from"react-redux";import{ rootReducer }from"./reducers";import App from"./App";const store =createStore(rootReducer);createRoot(document.getElementById("root")).render(<Providerstore={store}><App/></Provider>);
What’s happening
createStore(rootReducer) builds the store and runs the reducers once to get { todos: [], filter: "all" }.
<Provider store={store}> puts the store into a React context that connect, useSelector and useDispatch read. Without it, react-redux throws "could not find react-redux context value; please ensure the component is wrapped in a <Provider>" (the sandbox error).
Correcting my notes: they rendered with ReactDOM.render(<Provider …>, document.getElementById('root')). That API was deprecated in React 18 and removed in React 19; use createRoot(...).render(...) (see Rendering an app: createRoot vs ReactDOM.render).
The store is created once, outside any component, so re-renders never recreate it.
Reading and writing the store from components — first the connect higher-order component (the original API), then hooks (react-redux 7.1, 2019):
TodoList.jsx
import{ connect }from"react-redux";import{ toggleTodo, removeTodo }from"./actions";// A plain component: it only knows about propsfunctionTodoList({ todos, onToggle, onRemove }){return(<ul>{todos.map((todo)=>(<likey={todo.id}><label><inputtype="checkbox"checked={todo.completed}onChange={()=>onToggle(todo.id)}/>{todo.text}</label><buttononClick={()=>onRemove(todo.id)}>✕</button></li>))}</ul>);}// Which slice of the store becomes propsconstmapStateToProps=(state)=>({todos: state.filter ==="done"? state.todos.filter((t)=> t.completed): state.todos,});// Which actions become callback propsconstmapDispatchToProps=(dispatch)=>({onToggle:(id)=>dispatch(toggleTodo(id)),onRemove:(id)=>dispatch(removeTodo(id)),});exportdefaultconnect(mapStateToProps, mapDispatchToProps)(TodoList);
What’s happening
TodoList itself has no Redux in it. It's a presentational component driven entirely by props — the "container/presentational" split that connect encouraged (see Container and presentational components).
mapStateToProps(state) runs on every store change. It returns an object whose keys become props. connect shallow-compares the result with the previous one and re-renders TodoList only if a prop changed.
mapDispatchToProps(dispatch) runs once and turns actions into callback props. (It can also be an object — { onToggle: toggleTodo } — and connect wraps each creator in dispatch for you.)
connect(mapState, mapDispatch) returns a function that takes the component and returns a new wrapped component — a higher-order component. The default export is that wrapper, so importers get the connected version.
In react-redux 9.3, connect's type definitions are marked @deprecated ("We recommend using the useSelector and useDispatch hooks instead") and legacy_connect is exported as the same function without the editor warning. It still works — the sandbox test used it — but new code uses hooks.
useDispatch() returns the store's dispatch. useSelector(selector) runs the selector against the store and subscribes the component: it re-renders when the selector's result changes (compared with ===).
The selector returns a number (remaining), so a store change that doesn't change the count — such as renaming a todo — doesn't re-render AddTodo.
Submitting dispatches addTodo(text); the reducer appends the todo; connect re-renders TodoList and useSelector re-renders AddTodo with the new count. Both styles read the same store side by side.
The test added two todos ("2 left"), ticked one ("1 left"), and removed it with ✕. The final state is shown above.
Hooks won because they're less code, don't wrap the component, and compose with other hooks. connect is still fine to read and maintain.
connect (older)
Hooks
Read state
mapStateToProps
useSelector(selector)
Dispatch
mapDispatchToProps
useDispatch()
Shape
Wraps the component (HOC)
Called inside the component
Re-render check
Shallow-compares the props object
=== per useSelector (or pass shallowEqual)
Status in react-redux 9.3
Marked @deprecated in types; legacy_connect available
From my notes: "Redux THUNK (got so confused on this)…". It confuses almost everyone the first time, usually because of the "function that returns a function" — so let's go slowly. The whole idea fits in one sentence: Redux Thunk lets you dispatch a function instead of an object, and when you do, it calls that function with dispatch and getState so it can do async work and dispatch real actions later.
Why it's needed: a reducer must be pure and synchronous — it can't await fetch(). And as the last topic showed, the store's dispatch only accepts plain objects ("Actions must be plain objects. Instead, the actual type was: 'function'"). So where does "load the users from the API" go? Into a thunk: a function that wraps work to do later (that's what the word means in programming).
The mental model: a normal action is a letter you post to the reducer ("users loaded: […]"). A thunk is an errand runner you send out instead: "go fetch the users, and post me letters as things happen". The thunk middleware is the mailroom clerk: when something arrives, it checks — a letter goes on to the reducer as usual; an errand runner is sent off to do its job, and never reaches the reducer at all.
Step 1: the middleware is five lines
This is the actual logic of redux-thunk (the real one adds an optional extra argument):
thunkMiddleware.js
constthunk=({ dispatch, getState })=>(next)=>(action)=>{if(typeof action ==="function"){returnaction(dispatch, getState);// an errand runner: run it, don't pass it on}returnnext(action);// a letter: pass it on to the reducer};
What’s happening
Middleware has the curried shape store => next => action => … (the logger in Redux core concepts had the same shape). Redux calls the outer two layers once at setup; the innermost function runs for everydispatch.
If the dispatched value is a function, the middleware calls it, passing the store's dispatch and getState, and returns whatever the function returns. It does not call next, so the function never reaches the reducer — and never triggers the "must be plain objects" error.
If it's anything else (a normal action object), it calls next(action) and the action continues to the reducer as usual.
That's all Redux Thunk is. The sandbox swapped the real package for this hand-written version and the user-loading test still passed.
Step 2: the action creators
My notes' example, tidied: three plain action creators for the three moments of a request, and one thunk action creator:
The first three are ordinary action creators, exactly like addTodo in the classic topic. They return objects and do nothing else.
fetchUsers is where the "function returning a function" lives. Two layers, two jobs. The outer function, fetchUsers(), is what your component calls — it's an action creator, and it would receive arguments like a user ID (fetchUser(id)). The innerasync (dispatch, getState) => {…} is the thunk itself — it's what gets dispatched, and it's the middleware that calls it, not you.
Why not just one layer? Because dispatch(fetchUsers()) must dispatch something, and the thing it dispatches is the inner function. The outer layer exists to capture your arguments in a closure, the same reason any action creator exists.
Inside the thunk: dispatch "request" (so the UI can show a spinner), await the network, then dispatch "success" with the data or "failure" with the message. Each of those is a normal letter that goes through the reducer.
I changed two things from my notes: async/await instead of a .then chain, and a response.ok check — without it, a 500 response would be parsed and dispatched as "success" (the same bug as in useFetch).
Step 3: the reducer
The reducer is completely ordinary — it has no idea a thunk exists. It just handles three letters:
FETCH_USERS_REQUEST turns loading on and clears any previous error (my notes' version kept the old error; clearing it means a retry doesn't show a stale message).
FETCH_USERS_SUCCESS stores the users and turns loading off.
FETCH_USERS_FAILURE stores the message and turns loading off.
Notice there's nothing asynchronous here. All the async lives in the thunk; the reducer stays pure and synchronous, which is the whole point.
Step 4: the store
store.js
import{ legacy_createStore as createStore, applyMiddleware }from"redux";import{ thunk }from"redux-thunk";// named export since redux-thunk 3import{ userReducer }from"./reducer";exportconst store =createStore(userReducer,applyMiddleware(thunk));
What’s happening
applyMiddleware(thunk) wraps the store's dispatch so every dispatched value passes through the thunk middleware first.
Correcting my notes: they wrote import thunk from 'redux-thunk'. redux-thunk 3 (2023) has no default export — only the named thunk (and withExtraArgument). In Node the old import fails with "SyntaxError: The requested module 'redux-thunk' does not provide an export named 'default'"; under Vite it gave undefined, and applyMiddleware(undefined) then threw "middleware is not a function".
With Redux Toolkit's configureStore you don't write this at all: thunk is included by default (the sandbox dispatched a function into a plain configureStore store and it ran).
On mount the effect calls fetchUsers() — the outer function — which returns the thunk, and dispatches it. From the component's point of view, loading users is one line.
The component doesn't know about fetch, URLs or error handling. It reads three values with three selectors (my notes' approach, and a good one: each returns a value that's stable when unchanged).
In the test the first render showed "Loading..." (the thunk had already dispatched FETCH_USERS_REQUEST synchronously), and then findByText("Ervin Howell") found the user once the fake response resolved.
dispatch is stable, so [dispatch] means "run once on mount"; listing it just keeps the linter happy.
The whole thing, in order
This is the trace that makes it click. A logging middleware was placed after thunk, so it only sees what thunk passes on:
JavaScript
console.log("1. dispatching the thunk");const promise = store.dispatch(fetchUsers());
console.log("2. dispatch returned:", promise instanceofPromise?"a Promise": promise,"| loading =", store.getState().loading);await promise;
console.log("3. after await | state =",JSON.stringify(store.getState()));// 1. dispatching the thunk// → FETCH_USERS_REQUEST// 2. dispatch returned: a Promise | loading = true// → FETCH_USERS_SUCCESS// 3. after await | state = {"loading":false,"users":[{"id":1,"name":"Leanne Graham"}],"error":""}
What’s happening
fetchUsers() runs first and returns the inner async function. store.dispatch(thatFunction) hands it to the middleware chain.
The thunk middleware sees a function, so it calls it with (dispatch, getState) instead of passing it on. That's why the logger (placed after thunk) never logs the function itself.
The thunk starts running synchronously: its first line dispatches FETCH_USERS_REQUEST, which goes through the logger ("→ FETCH_USERS_REQUEST") to the reducer — loading is now true. Then it hits await fetch(…) and pauses.
Because the thunk is an async function, calling it returned a Promise, and the middleware returned that to us: dispatch gave back "a Promise", while loading = true. Line 2 printed before the network finished.
When the response arrives, the thunk resumes and dispatches FETCH_USERS_SUCCESS. await promise waits for the thunk to finish, so line 3 sees loading: false and the users. Being able to await dispatch(thunk) is handy in event handlers ("save, then navigate").
Thunks also get getState, so they can decide whether to do anything:
JavaScript
constfetchUsersIfNeeded=()=>(dispatch, getState)=>{if(getState().users.length >0)return Promise.resolve("cached");returndispatch(fetchUsers());};await store.dispatch(fetchUsersIfNeeded());// fetchesawait store.dispatch(fetchUsersIfNeeded());// → "cached", fetch called 1 time in total
What’s happening
This thunk reads the current state before doing anything. On the first dispatch the list is empty, so it dispatches the real fetchUsers() thunk — thunks can dispatch other thunks.
On the second dispatch the users are there, so it returns "cached" without touching the network. The sandbox counted one fetch call for the two dispatches.
This "check, then fetch" logic is the reason thunks get getState — it's where conditional and multi-step async logic lives, and it keeps components simple. createAsyncThunk builds this in as the condition option.
?Forgetting the parentheses
JavaScript
store.dispatch(fetchUsers);// note: no ()
What happens?
Show answer
Nothing at all — no request, no error, no state change.
What’s happening
fetchUsers (without calling it) is the outer action creator, and it's a function — so the thunk middleware treats it as a thunk.
The middleware calls fetchUsers(dispatch, getState). The outer function ignores those arguments and simply returns the inner thunk.
That returned inner function is handed back to you as dispatch's return value — it is never called. The sandbox showed: returned: function | fetch calls: 0, state unchanged.
No error is thrown, because dispatching a function is now legal. If an async action "does nothing", check for a missing () first.
AdvancedMiddleware order and injecting dependencies
Order matters. Middleware runs in the order you list it. With applyMiddleware(logger, thunk), the logger sees the raw function first ("→ a function arrives"), then thunk runs it, and the logger sees the plain actions it dispatches too (because a thunk's dispatch goes through the whole chain again). With applyMiddleware(thunk, logger) — as in the trace above — the logger only ever sees plain objects.
Extra argument.withExtraArgument(api) (from redux-thunk) or configureStore({ middleware: (gDM) => gDM({ thunk: { extraArgument: api } }) }) passes a third argument to every thunk: (dispatch, getState, api) => …. Thunks then call api.getUsers() instead of importing fetch directly, which makes them trivial to test with a fake api.
Redux Toolkit (RTK) is the official, recommended way to write Redux. It's the same store, actions and reducers underneath, minus the boilerplate. From my notes: "REDUX TOOLKIT (createSlice).. Better way to create reducers and action creators…" and "PRE SLICE you had to define actions and action creators yourself…". And my notes' verdict: "BIGGEST advantage in my mind!!!!!!! (simplifies reducer logic)".
The two big ideas:
createSlice takes a name, an initial state and an object of "case reducers", and generates the action types, the action creators and the reducer from it. One definition instead of constants + creators + switch.
Reducers can "mutate". RTK runs your reducer through Immer, which gives you a draft copy; you write state.loading = true and Immer produces a new immutable state from the changes (see Immer for nested updates).
My notes' "before and after", which shows the difference better than any description:
What createSlice generated, as the sandbox printed it:
JavaScript
fetchRequest();// {"type":"fetch/fetchRequest"}fetchSuccess([1,2]);// {"type":"fetch/fetchSuccess","payload":[1,2]}
fetchSuccess.type;// "fetch/fetchSuccess"
fetchSuccess.match({type:"fetch/fetchSuccess"});// trueconst before =fetchReducer(undefined,{type:"@@init"});const after =fetchReducer(before,fetchRequest());// before: {"data":null,"loading":false,"error":null}// after: {"data":null,"loading":true,"error":null} Object.isFrozen(after) → true
What’s happening
Each key in reducers became an action creator. The type is "<slice name>/<key>": fetch/fetchRequest. No constants to declare or import.
Calling a creator with an argument puts it in payload — fetchSuccess([1, 2]) gives payload: [1, 2]. That's the convention every case reducer reads from.
Each creator carries its .type and a .match(action) type guard, which you'll use in extraReducers and middleware instead of comparing strings.
fetchRequest(state) { state.loading = true; } looks like a mutation, but before is unchanged and after is a new object. Immer recorded the assignment on a draft and built the new state. RTK also freezes the result in development (Object.isFrozen → true), so accidental mutation outside a reducer fails loudly.
This is my notes' "biggest advantage" in action: the reducer logic is just the assignments, with no spreading and no switch.
A full example — a todos slice, the store, and a component:
todoAdded uses the object form with a prepare callback. todoAdded("Buy milk") runs prepare first, which builds the payload — {"type":"todos/todoAdded","payload":{"id":1,"text":"Buy milk"}} — so ID generation stays out of the reducer and the reducer stays pure.
state.items.push(…) and todo.completed = !todo.completed are Immer "mutations" of a draft; each produces a new items array and a new todo object, leaving untouched todos shared with the old state.
todoRemoved shows the other Immer style: assigning a new value to a property of the draft (state.items = …filter(…)) is also fine.
selectors (new in RTK 2) defines selectors next to the slice. They receive the slice's state, and todosSlice.selectors wraps them to accept the root state, so selectRemaining(store.getState()) returned 1 in the test.
selectVisibleTodos uses createSelector (from Reselect, re-exported by RTK): it recomputes only when items or filter change, otherwise it returns the same array — which fixes the "Selector unknown returned a different result" warning from the classic topic.
store.js
import{ configureStore }from"@reduxjs/toolkit";import todosReducer from"./todosSlice";exportconst store =configureStore({reducer:{todos: todosReducer,},});
What’s happening
reducer: { todos: todosReducer } is passed to combineReducers for you; the root state is { todos: { items, filter } }.
configureStore adds, with no configuration: the thunk middleware, the Redux DevTools connection, and in development an immutability check and a serializability check middleware (below).
Compare with the classic createStore(rootReducer, compose(applyMiddleware(thunk), window.__REDUX_DEVTOOLS_EXTENSION__?.())) that every project used to copy-paste.
The React side is identical to the classic hooks version: useDispatch, useSelector, Provider at the root. RTK changes how you define state, not how components use it.
dispatch(todoAdded(text)) sends { type: "todos/todoAdded", payload: { id, text } }; the slice reducer handles it.
useSelector(selectVisibleTodos) gets the memoised array, so Todos re-renders only when the todos or filter actually change — no warning.
The test added two todos and ticked one: "1 left". (The IDs start at 2 because an earlier test in the same file had already called todoAdded once — the counter lives in the module.)
The development checks configureStore adds, and what they print — real messages from the sandbox:
JavaScript
// 1. Mutating AND returning in the same case reduceroops(state){ state.n++;return{n:99};}// Error: [Immer] An immer producer returned a new value *and* modified its draft.// Either return a new value *or* modify the draft.// 2. Mutating state outside a reducer
store.getState().todos.items[0].text ="hacked";// TypeError: Cannot assign to read only property 'text' of object '#<Object>'// 3. Putting a Date (or a class instance, Promise, function) in an actiondispatch({type:"todos/todoAdded",payload:{id:9,text:"x",due:newDate(0)}});// console.error: A non-serializable value was detected in an action, in the path: `payload.due`.// console.error: A non-serializable value was detected in the state, in the path: `todos.items.0.due`.
What’s happening
Immer lets you either change the draft or return a brand-new state — not both. Returning is useful for "replace everything" cases like a reset: reset: () => initialState.
The state you get from getState() or useSelector is frozen in development, so writing to it throws instead of silently corrupting the store (and breaking re-renders).
The serializability check warns about values that can't be turned into JSON. Redux state should be plain data so DevTools, persistence and time-travel work: store due as an ISO string or a timestamp.
All three checks run only in development and are stripped from production builds.
AdvancedWhat changed in Redux Toolkit 2 (and Redux 5)
extraReducers object syntax removed.extraReducers: { "a/b": reducer } now throws when the reducer runs: "The object notation for createSlice.extraReducers has been removed. Please use the 'builder callback' notation instead". Use extraReducers: (builder) => builder.addCase(…) — shown in createAsyncThunk.
selectors field in createSlice (used above), plus combineSlices for lazy-loading slices with code-split routes.
Redux 5:createStore is marked deprecated in favour of configureStore, action type must be a string, and the AnyAction type is deprecated in favour of UnknownAction.
Immer 10+ under the hood (the sandbox has Immer 11), with better performance for large states.
TypeScript: typed hooks are now one line each — useSelector.withTypes<RootState>() and useDispatch.withTypes<AppDispatch>() (react-redux 9.1+). See Typing context for typing shared state in general.
createAsyncThunk is the Redux Toolkit version of the hand-written thunk from Redux Thunk. You give it an action type prefix and an async function; it generates the thunk and the three lifecycle action creators — pending, fulfilled, rejected — and dispatches them for you at the right moments. You handle those actions in a slice's extraReducers.
The mental model: it's my notes' fetchUsersRequest / fetchUsersSuccess / fetchUsersFailure + fetchUsers thunk, written once by RTK so you only write the part that's different each time — the API call. My notes: "Redux thunk and Redux Toolkit Async Thunk are good enough lol" — agreed, for most apps they are.
usersSlice.js
import{ createSlice, createAsyncThunk }from"@reduxjs/toolkit";exportconst fetchUsers =createAsyncThunk("users/fetchUsers",async(_arg,{ signal, rejectWithValue })=>{const response =awaitfetch("https://jsonplaceholder.typicode.com/users",{ signal });if(!response.ok){returnrejectWithValue(`Server said ${response.status}`);}return response.json();// becomes action.payload of "fulfilled"},{// Skip the request if one is already running or the data is already herecondition:(_arg,{ getState })=>{const{ status }=getState().users;return status !=="loading"&& status !=="succeeded";},});const usersSlice =createSlice({name:"users",initialState:{list:[],status:"idle",error:null},reducers:{usersReset:()=>({list:[],status:"idle",error:null}),},extraReducers:(builder)=>{
builder
.addCase(fetchUsers.pending,(state)=>{
state.status ="loading";
state.error =null;}).addCase(fetchUsers.fulfilled,(state, action)=>{
state.status ="succeeded";
state.list = action.payload;}).addCase(fetchUsers.rejected,(state, action)=>{
state.status ="failed";
state.error = action.payload ?? action.error.message;});},});exportconst{ usersReset }= usersSlice.actions;exportdefault usersSlice.reducer;
What’s happening
createAsyncThunk("users/fetchUsers", payloadCreator) returns fetchUsers, a thunk action creator, with fetchUsers.pending, .fulfilled and .rejected attached. Their types are users/fetchUsers/pending, users/fetchUsers/fulfilled, users/fetchUsers/rejected.
The payload creator is the only async code you write. Whatever it returns becomes the fulfilled action's payload. If it throws, RTK dispatches rejected with a serialised action.error ({ name, message, stack }). rejectWithValue(x) is the controlled way to fail: rejected with action.payload = x.
The second argument (thunkAPI) also has dispatch, getState, extra and an AbortSignal. Passing signal to fetch means the request can be cancelled with promise.abort().
extraReducers handles actions that this slice didn't define — here, the thunk's three lifecycle actions. It takes a builder callback; addCase(fetchUsers.pending, …) matches by the action creator, no type strings.
Status is one field ("idle" | "loading" | "succeeded" | "failed") rather than separate booleans, so impossible combinations like loading: true with an error can't happen.
What actually gets dispatched (a logging middleware added to the store):
JavaScript
await store.dispatch(fetchUsers());// → users/fetchUsers/pending// → users/fetchUsers/fulfilled [{"id":1,"name":"Leanne"}]// state.users: {"list":[{"id":1,"name":"Leanne"}],"status":"succeeded","error":null}const second =await store.dispatch(fetchUsers());// (nothing dispatched) second.type: users/fetchUsers/rejected, second.meta.condition: true// fetch called 1 time in total// With a 503 response:// → users/fetchUsers/pending// → users/fetchUsers/rejected "Server said 503"// state.users.error: "Server said 503"
What’s happening
Dispatching fetchUsers() runs the generated thunk: it dispatches pending synchronously, runs the payload creator, and dispatches fulfilled with the returned users. Exactly my notes' request/success flow, generated.
The second dispatch hits condition: status is "succeeded", so it returns false. RTK then dispatches nothing and doesn't call the payload creator; the promise resolves with a rejected action marked meta.condition: true, so callers can tell it was skipped. One fetch in total.
With a 503, rejectWithValue("Server said 503") produced rejected with that string as payload, and the reducer stored it. When the payload creator throws instead (the sandbox made fetch throw TypeError: Failed to fetch), action.payload is undefined and action.error.message is "Failed to fetch" — that's why the reducer reads action.payload ?? action.error.message.
dispatch(fetchUsers()) always resolves (to the final action) — it never rejects. To use try/catch in a component, call .unwrap(): await dispatch(fetchUsers()).unwrap() returns the payload or throws the rejected value ("unwrap threw: Server said 503" in the sandbox).
Same shape as the classic UserList, but the slice and thunk did all the action plumbing.
useSelector((state) => state.users) returns the slice object itself — not a new object — so it's ===-stable until the slice changes. (Building { list, status } in the selector would trigger the "different result" warning.)
Under <StrictMode> the effect runs twice in development. The first run dispatches pending (status "loading"); the second run's condition sees "loading" and skips. The sandbox counted onefetch and rendered both users.
Not aborting on unmount is deliberate here: unlike useFetch, the result goes into the global store, not into an unmounted component's state, so letting it finish is harmless and the data is ready next time.
Look at how much of this chapter's Redux code is about one thing: fetching data from a server and keeping track of loading/error/data. RTK Query is Redux Toolkit's built-in answer: you describe your API endpoints once, and it generates hooks that fetch, cache, deduplicate, refetch and invalidate for you — the same job as TanStack Query, but storing the cache inside your Redux store.
The mental model: stop thinking "actions and reducers for each request" and think "a cache of server responses, keyed by endpoint + arguments". A component says "I need getTodos()"; if it's in the cache and fresh, it gets it instantly; if not, RTK Query fetches it once, no matter how many components asked. A mutation says "this changed the Todo data", and every query that provided Todo refetches.
api.js
import{ createApi, fetchBaseQuery }from"@reduxjs/toolkit/query/react";exportconst api =createApi({reducerPath:"api",baseQuery:fetchBaseQuery({baseUrl:"http://localhost/api"}),tagTypes:["Todo"],endpoints:(build)=>({getTodos: build.query({query:()=>"todos",providesTags:["Todo"],}),addTodo: build.mutation({query:(title)=>({url:"todos",method:"POST",body:{ title }}),invalidatesTags:["Todo"],// refetch every query that provides "Todo"}),}),});exportconst{ useGetTodosQuery, useAddTodoMutation }= api;
What’s happening
createApi from @reduxjs/toolkit/query/react (the /react entry point is what generates hooks). reducerPath is the key the cache will live under in the store.
fetchBaseQuery is a small wrapper around fetch: it prefixes baseUrl, serialises body as JSON, parses the response, and turns non-2xx statuses into { error: { status, data } }.
build.query defines a read endpoint; build.mutation a write. getTodos's query returns the URL path ("todos"); addTodo returns a request description with method and body.
Tags connect them: getTodos says its data provides"Todo"; addTodo says it invalidates"Todo". After a successful mutation, RTK Query refetches every active query that provides that tag.
The hooks are generated from the endpoint names: getTodos → useGetTodosQuery, addTodo → useAddTodoMutation.
store.js
import{ configureStore }from"@reduxjs/toolkit";import{ api }from"./api";exportconst store =configureStore({reducer:{[api.reducerPath]: api.reducer },middleware:(getDefault)=>getDefault().concat(api.middleware),});
What’s happening
api.reducer holds the cache (queries, mutations, subscriptions) under state.api.
api.middleware does the actual work: starting requests, counting subscribers, removing unused cache entries after 60 seconds by default (keepUnusedDataFor), and running tag invalidation.
Forgetting the middleware is the classic setup mistake. In development the first query then throws: "Warning: Middleware for RTK-Query API at reducerPath "api" has not been added to the store." (the sandbox error, word for word).
TodoApp.jsx
import{ useGetTodosQuery, useAddTodoMutation }from"./api";exportfunctionTodoList(){const{data: todos =[], isLoading, isFetching, error }=useGetTodosQuery();if(isLoading)return<p>Loading...</p>;if(error)return<p>Error {error.status}</p>;return(<ularia-busy={isFetching}>{todos.map((t)=>(<likey={t.id}>{t.title}</li>))}</ul>);}exportfunctionTodoCount(){const{data: todos =[]}=useGetTodosQuery();// same cache entry, no second requestreturn<p>{todos.length} todos</p>;}exportfunctionAddTodo(){const[addTodo,{ isLoading }]=useAddTodoMutation();return(<buttondisabled={isLoading}onClick={()=>addTodo("Learn RTK Query")}>
Add
</button>);}// Render <TodoList /> <TodoCount /> <AddTodo />, then click Add. Requests made:// fetch GET /api/todos// fetch POST /api/todos// fetch GET /api/todos// cache keys: [ 'getTodos(undefined)' ]
What’s happening
TodoList mounts and calls useGetTodosQuery(). Nothing is cached, so it shows "Loading..." and RTK Query sends GET /api/todos.
TodoCount calls the same hook with the same (no) argument in the same render. RTK Query sees the cache entry getTodos(undefined) already loading and subscribes it to the same request — still one GET. Both components show the data when it arrives ("Learn Redux", "1 todos").
Clicking Add calls the mutation trigger: POST /api/todos with {"title":"Learn RTK Query"}. On success, invalidatesTags: ["Todo"] marks getTodos stale and RTK Query refetches it: the second GET.
During that refetch isLoading stays false (there's already data) while isFetching is true — so the list stays on screen instead of flashing "Loading...". Then both components show the new todo ("2 todos").
There's no slice, no thunk, no extraReducers and no useEffect anywhere in this code. That's why RTK Query (or TanStack Query) is the recommended way to do data fetching with Redux today.
From my notes: "Not gonna go into Saga man…. Redux thunk and Redux Toolkit Async Thunk are good enough lol" — and for new code that's still my advice. But my notes' micro-frontend section describes an architecture where "React apps use Redux/Redux-Saga to handle state and the dispatching of actions which result in GraphQL queries and mutations", so it's worth being able to explain sagas when an existing codebase uses them.
Redux Saga is middleware that runs generator functions ("sagas") alongside your store. A saga listens for actions and reacts to them — calling APIs, dispatching new actions, waiting, racing, cancelling. The key idea is that a saga never performs side effects itself: it yields descriptions of effects — plain objects like "call this function with these arguments" or "dispatch this action" — and the saga middleware performs them and resumes the generator with the result. (Generators and yield are covered in the JavaScript notes.)
The mental model: a thunk is an errand runner who does the errand. A saga is a manager who writes instructions ("call the API; then put this action") and hands each one to an assistant (the middleware), waiting for the result before writing the next. Because the instructions are just objects, you can test the manager by reading the instructions — no network, no mocks.
What it looks like with the real library. This block was not executed: redux-saga (current version 1.5) isn't installed in the sandbox, so it's written against the library's documented API:
sagas.js
import createSagaMiddleware from"redux-saga";import{ call, put, takeLatest }from"redux-saga/effects";import{ configureStore }from"@reduxjs/toolkit";function*fetchUsersSaga(){try{const users =yieldcall(api.getUsers);// "please call api.getUsers()"yieldput({type:"users/loaded",payload: users });// "please dispatch this"}catch(err){yieldput({type:"users/failed",payload: err.message });}}function*rootSaga(){// On every "users/requested", run fetchUsersSaga — cancelling the previous run if still goingyieldtakeLatest("users/requested", fetchUsersSaga);}const sagaMiddleware =createSagaMiddleware();exportconst store =configureStore({reducer: usersReducer,middleware:(getDefault)=>getDefault({thunk:false}).concat(sagaMiddleware),});
sagaMiddleware.run(rootSaga);// A component just dispatches a plain action:// dispatch({ type: "users/requested" });
What’s happening
Components dispatch an ordinary object (users/requested) — with sagas, the async logic is triggered by actions rather than by dispatching functions.
rootSaga runs at startup. takeLatest("users/requested", fetchUsersSaga) watches for that action and starts fetchUsersSaga each time; if a previous run is still waiting, it's cancelled. Built-in cancellation and concurrency helpers (takeLatest, takeEvery, debounce, race, fork, cancel) are what sagas are good at.
yield call(api.getUsers) yields the object { CALL: { fn, args } }. The middleware calls the function, waits for its promise, and resumes the generator with the result, so users gets the data.
yield put(action) yields "dispatch this", and the middleware dispatches it to the reducer. If call rejected, the middleware throws the error into the generator, so the ordinary try/catch works.
getDefault({ thunk: false }) turns off RTK's built-in thunk middleware, since this app does async with sagas instead.
AdvancedHow the middleware runs a saga
To see why yielding descriptions is useful, here's a toy runner that does the core of what redux-saga does — this one was run with Node:
toy-saga.mjs
// Effects are plain objects that DESCRIBE work. The saga never does the work itself.constcall=(fn,...args)=>({effect:"call", fn, args });constput=(action)=>({effect:"put", action });function*fetchUsersSaga(){try{const users =yieldcall(api.getUsers);yieldput({type:"users/loaded",payload: users });}catch(err){yieldput({type:"users/failed",payload: err.message });}}const api ={getUsers:async()=>[{id:1,name:"Leanne"}]};constdispatch=(action)=> console.log("dispatch",JSON.stringify(action));asyncfunctionrun(saga){const it =saga();let step = it.next();while(!step.done){const eff = step.value;
console.log("saga yielded", eff.effect);if(eff.effect ==="call"){try{
step = it.next(await eff.fn(...eff.args));// resume with the result}catch(err){
step = it.throw(err);// or throw into the generator}}elseif(eff.effect ==="put"){dispatch(eff.action);
step = it.next();}}}awaitrun(fetchUsersSaga);// saga yielded call// saga yielded put// dispatch {"type":"users/loaded","payload":[{"id":1,"name":"Leanne"}]}// Testing a saga = stepping it by hand. No mocks, no network.const gen =fetchUsersSaga();
gen.next().value.fn === api.getUsers;// trueJSON.stringify(gen.next([{id:9}]).value.action);// {"type":"users/loaded","payload":[{"id":9}]}
What’s happening
call and put don't do anything — they return objects. fetchUsersSaga is a generator, so calling it just creates an iterator that hasn't started.
run steps the generator. The first it.next() runs until yield call(api.getUsers) and hands back the description: "saga yielded call".
The runner performs the call, awaits it, and resumes the generator with it.next(result) — inside the saga, users becomes that value. The saga then yields a put, which the runner dispatches.
The test at the bottom is why teams liked sagas: drive the generator by hand, check the description it yields (fn === api.getUsers), feed in fake data ([{ id: 9 }]), and check the next description. No fetch mock needed.
Real redux-saga adds the take* watchers, fork/cancel, channels for WebSockets and an error model on top — but the "yield an instruction, get the result back" loop is this.
Zustand ("state" in German) is a tiny state library (v5) that's become the most popular lightweight alternative to Redux. A store is a hook you create with create(): state and the functions that change it live together in one object, components read slices with a selector, and there's no provider, no actions, no reducers unless you want them.
The mental model: a Zustand store is a module-level useState that any component can subscribe to — with selectors, so each component re-renders only when the piece it selected changes. Under the hood it's the same pattern as the tiny store in useSyncExternalStore.
create(initializer) calls your function once with set and get and uses the returned object as the initial state. State (items) and actions (addItem, clear) sit side by side.
set(partial)merges the partial object into the state (one level deep, like a class component's setState), so set({ items: [] }) leaves addItem and the others alone. set(state => partial) gives you the current state first.
Updates are immutable, like in React: addItem builds a new items array with spread/map. (Zustand has an immer middleware if you prefer mutable-style updates.)
get() reads the current state inside actions — total uses it to compute a derived value on demand.
The result, useCartStore, is both a React hook and a store object with getState, setState and subscribe.
There's no <Provider> anywhere. Any component imports the hook and calls it with a selector.
CartBadge selects a number (the total quantity). After each of the three clicks the number changes (1 → 2 → 3), so it re-renders: 1 + 3 = 4 renders.
Each AddButton selects state.addItem — a function created once in create, so its identity never changes. Zustand compares selector results with Object.is, sees no change, and never re-renders the buttons: 1 render each.
Total calls state.total() inside the selector and gets a number: "Total: $45" after two books and a pen.
This is the main practical difference from context: components subscribe to slices, so changing the cart doesn't touch components that only use its actions.
The store also works outside React — in tests, event listeners, or non-React code:
getState() returns the current state object, including the actions, so you can call addItem directly.
subscribe(listener) calls the listener with the new and previous state after every set, and returns an unsubscribe function.
In tests you can reset the store between cases with useCartStore.setState(initialState, true) — the true replaces instead of merging.
Calling the hook with no selector — useCartStore() — subscribes to the whole store, so the component re-renders on every change; in the sandbox, setting an unrelated key re-rendered it. Fine for tiny stores, but select what you use.
AdvancedMiddleware: persist and devtools
Zustand's middleware wraps the initializer. persist saves the store to localStorage (or any storage) and rehydrates it on load:
persist(initializer, { name }) returns a new initializer that also subscribes to changes and writes them under the key "settings".
Functions aren't saved — only the data: {"state":{"theme":"dark"},"version":0}. version (with a migrate option) lets you change the shape later without breaking users' saved state.
On the next page load, persist reads the key and merges it into the initial state, so the theme survives a reload — a ready-made version of useLocalStorage.
devtools(initializer, { name }) connects the store to Redux DevTools, so you get the same action log and time travel as Redux.
Zustand vs Redux Toolkit, briefly: Zustand is less code and has no provider, which makes it great for small-to-medium client state. Redux Toolkit has stronger conventions (slices, actions in DevTools by name), middleware and RTK Query, which pay off in large teams and apps. Both use selectors and both are built on useSyncExternalStore, so performance is similar.
useState — "Mostly for local state within few components"
useReducer — "Mostly for local state within few components"
useContext — "Pass theme, authentication information etc."
Redux — "IMPORTANTTTT!!!! (Redux also comes with powerful tools like Redux DevTools for debugging and middleware (like Redux Thunk or Redux Saga) for handling asynchronous actions.)"
That's still the right order to reach for things, and the Redux note is still the reason to pick Redux. The big update since my notes: most of what apps used to put in Redux was server data, and that now belongs in a server-state cache. So the modern question isn't "Context or Redux?" but first "what kind of state is this?":
a canvas editor, a big multi-step workflow, offline queue
Zustand or Redux Toolkit
Complex flows with strict states
checkout, wizards, device pairing
A state machine (XState) or a carefully designed reducer
Questions to ask, in order:
Does only one component use it? Keep it local. Most state should be local.
Is it really server data? Use a query library; don't copy API responses into a store by hand.
Could it live in the URL? Then it's shareable, bookmarkable and survives reload for free.
Is it shared but changes rarely? Context is enough.
Is it shared, changes often, and needed by many components in different slices? Use a store with selectors: Zustand for less ceremony, Redux Toolkit for strong conventions, middleware and DevTools across a big team.
?Which tool?
A product page shows: the product (from GET /products/42), a size picker, a "recently viewed" strip shared with other pages and remembered across reloads, and the signed-in user's name in the header. Where does each piece of state go?
Show answer
Four different places — one per kind of state.
What’s happening
The product is server data: useQuery(["product", 42], …) with TanStack Query (or useGetProductQuery(42) with RTK Query). It's cached, deduplicated, and refetched when stale; no store code needed.
The selected size is local UI state used by the picker and the add-to-cart button on the same page: useState in their common parent. If you want "?size=M" to be shareable, put it in the URL instead.
Recently viewed is client state shared across pages and persisted: a small Zustand store with the persist middleware (or a Redux slice plus persistence).
The user's name comes from the auth session, which changes only on login/logout: an AuthProvider + useAuth() context — or, if the user object comes from an API, a query that the auth context exposes.
The point interviewers look for: you didn't put everything in one global store. Each piece went to the simplest tool that fits its kind.