State Management with Redux

Redux is a single JavaScript object holding the state several parts of your application care about, plus one rule for changing it: you never assign to that object. You dispatch a plain action describing what happened, and a pure reducer returns the next object. Every change therefore has a name, an order and a payload, which is why a Redux application can be replayed, logged, time-travelled and tested without a browser.That discipline costs boilerplate, and Redux Toolkit removes almost all of it. The official package (@reduxjs/toolkit 2.12.0, released 15 May 2026) wraps Redux 5.0.1, Immer 11, Reselect 5 and redux-thunk 3, and is how the Redux team recommends writing Redux today. createStore, applyMiddleware and connect() still work, and you meet them here only so you can read older code. npm install @reduxjs/toolkit react-redux Listing 2.161: Installing Redux Toolkit and the React bindings react-redux 9.3.0 is the binding layer between the store and your components; you never install redux yourself. It needs React 18 or 19, because it is built on useSyncExternalStore (Section 2.8.8).Figure 2.58: One dispatch, one pass through the store, one re-render

2.18.1 When an Application Needs a Store

Most React applications do not need Redux, and the Redux team says so in its own documentation. React's own answers — useState for local values, lifting state up for two siblings (Section 2.3.7), context plus useReducer for a tree-wide value that changes rarely (Section 2.5.8) — cover most of them. Reach past those only on a symptom.Three symptoms justify a store. Distance: a value is edited in one corner of the tree and read in another, through a dozen components that only forward it. Volume of transitions: the same state changes from many places by many rules, and you want those rules in one named file. Inspectability: you need the exact sequence of changes behind a bug report, which Redux DevTools gives you and nothing in React does.One symptom that looks like a store problem and is not: server data. A products list fetched from an Express API is not application state; it is a cache of somebody else's, needing deduplication, background refetching and invalidation more than reducers. Use a query library — RTK Query (Section 2.18.17) or TanStack Query (Section 2.19.9) — and keep the store for what is yours.The honest comparison with context and useReducer is narrow. Both already give you dispatch and a pure reducer, and context gives you distance. Redux adds middleware, a DevTools timeline, memoized selectors that stop unrelated components re-rendering, and a store outside the React tree that non-React code can read. On a small team with a handful of values, context wins; when several teams share one application, the store usually does, because "what can change this value?" has a grep-able answer. None of it is all-or-nothing: form inputs belong in useState even when the cart lives in Redux.

2.18.2 Store, Actions and Reducers

Three nouns carry the whole model, and they are worth learning outside React. An action is a plain object with a required type string and, by convention, a payload; it describes an event that happened — 'cart/itemAdded' — not a command to a component. A reducer is a pure function (state, action) => nextState. A store holds one state value, runs the reducer for every dispatched action, and notifies subscribers afterwards.

import { configureStore } from '@reduxjs/toolkit';

const counter = (state = 0, action) => // a reducer: pure, synchronous

action.type === 'counter/incremented' ? state + 1

: action.type === 'counter/addedBy' ? state + action.payload

: state; // unknown action: hand back what you got

const store = configureStore({ reducer: counter });

const stop = store.subscribe(() => console.log('state is now', store.getState()));

store.dispatch({ type: 'counter/incremented' });

store.dispatch({ type: 'counter/addedBy', payload: 10 });

store.dispatch({ type: 'counter/unknown' }); // reducer returns state, subscribers still run

stop();

Listing 2.162: The store API, without React

state is now 1

state is now 11

state is now 11

Output of Listing 2.162 Those three methods, plus replaceReducer(next) for code splitting, are the store's entire surface; slices, thunks, selectors and queries are conveniences built on them.The unknown-action line matters more than it looks. Reducers see every action, so returning the old state is not politeness: leave that branch out and the first unrelated dispatch wipes the slice to undefined. The initial value arrives the same way — the store dispatches a setup action once, and the default parameter answers.

2.18.3 Combining Reducers

combineReducers splits the state object by key and gives each key its own reducer, which sees only its own slice and cannot touch the others.

import { combineReducers, configureStore } from '@reduxjs/toolkit';

const items = (state = [], action) =>

action.type === 'cart/itemAdded' ? [...state, action.payload] : state;

const filter = (state = 'all', action) =>

action.type === 'filters/set' ? action.payload : state;

const store = configureStore({ reducer: { items, filter } }); // combined for you

store.dispatch({ type: 'cart/itemAdded', payload: 'Mug' });

store.dispatch({ type: 'filters/set', payload: 'inStock' });

console.log(store.getState(), combineReducers({ items, filter })(undefined, {}));

Listing 2.163: Two independent reducers sharing one store { items: [ 'Mug' ], filter: 'inStock' } { items: [], filter: 'all' } Output of Listing 2.163 The second call shows what the combined reducer produces from nothing: each key's default. Passing an object to configureStore's reducer key calls combineReducers for you, so the explicit import is needed only when you nest: combineReducers({ user, cart: combineReducers({ items, coupon }) }) produces state.cart.items.The combined reducer runs every child reducer on every action, so 'user/loggedOut' can empty the cart and clear the session in one dispatch with no coordination between the two files. It is cheap, because an unchanged slice keeps its old object and React Redux then skips the components reading it. Development enforces two rules loudly: a child reducer must never return undefined, and state keys with no matching reducer are dropped with a warning.

2.18.4 Middleware

Subscribers run after the reducer. Middleware runs before it, wrapped around dispatch, which is where logging, crash reporting, analytics and every asynchronous pattern in Redux live. A middleware is a function of three nested arrows, storeApi => next => action => result: storeApi gives you getState and dispatch, and next is the rest of the pipeline.

const counter = (s = 0, a) => a.type === 'counter/addedBy' ? s + a.payload : s;

const logger = store => next => action => {

console.log('->', action.type, 'before:', store.getState());

const result = next(action); // pass it on; omit this and the action is swallowed

console.log('<-', action.type, 'after: ', store.getState());

return result; // dispatch() returns whatever this returns

};

const cap = store => next => action => // rewrite the action rather than block it

action.type === 'counter/addedBy' && store.getState() >= 5

? next({ ...action, payload: 0 }) : next(action);

const store = configureStore({ reducer: counter, middleware: g => g().concat(logger, cap) });

for (const n of [7, 7]) store.dispatch({ type: 'counter/addedBy', payload: n });

Listing 2.164: A logger and an action-rewriting middleware

-> counter/addedBy before: 0

<- counter/addedBy after: 7

-> counter/addedBy before: 7

<- counter/addedBy after: 7

Output of Listing 2.164 The second add changed nothing: cap rewrote its payload before the reducer saw it. getState() reads the old state in the before log and the new one in the after log, because next returns only once the reducer has run.Figure 2.59: Middleware wraps dispatch: outermost in, reducer at the bottom, outermost outOrder is the array order: the first entry is outermost and sees the action first. Older code spells the same wiring createStore(reducer, applyMiddleware(a, b)).

2.18.5 Thunks

Reducers are synchronous and pure, so a fetch cannot live in one. A thunk is the escape hatch: a function you pass to dispatch instead of an action object. The thunk middleware — installed by configureStore with no setup — calls it with (dispatch, getState, extraArgument), so the function can await, branch on the current state, and dispatch as many plain actions as it needs.

const checkout = (state = { status: 'idle', order: null }, action) =>

action.type === 'checkout/started' ? { ...state, status: 'sending' }

: action.type === 'checkout/done' ? { status: 'paid', order: action.payload }

: state;

const store = configureStore({ reducer: checkout });

const submitOrder = (total) => async (dispatch, getState) => { // a thunk

if (getState().status === 'sending') return 'ignored'; // no double submit

dispatch({ type: 'checkout/started' });

await new Promise(r => setTimeout(r, 200)); // stands in for POST /api/orders

dispatch({ type: 'checkout/done', payload: { id: 'A-17', total } });

return getState().order; // dispatch() returns the thunk's promise

};

const first = store.dispatch(submitOrder(27));

console.log('second dispatch ->', await store.dispatch(submitOrder(27)));

console.log('first dispatch ->', await first);

Listing 2.165: A thunk that reads state, awaits, and dispatches twice

second dispatch -> ignored

first dispatch -> { id: 'A-17', total: 27 }

Output of Listing 2.165 A thunk's return value comes back from dispatch — here a promise, so the caller can await a save and then navigate. The duplicate submit is rejected by reading getState(), a check that belongs in the thunk because the component is not the only thing that can start a checkout. The third argument, configured once with getDefaultMiddleware({ thunk: { extraArgument: api } }), lets every thunk call api.post(...) without importing anything — that is how you swap in a fake client for tests. What thunks lack is structure: every one that loads data writes the same started/succeeded/failed trio by hand, which is what Section 2.18.13 replaces.

2.18.6 Redux DevTools

The reason to accept Redux's ceremony is visible the first time you open the DevTools tab. Because every change is a named action and every state is a new immutable object, the extension lists the actions in order, shows the diff each produced, jumps back to any earlier state, and exports the session as replayable JSON. Install it for Chrome, Firefox or Edge from the Redux DevTools repository (https://github.com/reduxjs/redux-devtools); configureStore connects to it automatically.

const store = configureStore({

reducer: rootReducer,

devTools: process.env.NODE_ENV !== 'production' && {

name: 'catalog', // shown in the instance picker

trace: true, // click an action to see the line that dispatched it

actionsDenylist: ['keystroke/typed'], // hide chatty actions

},

});

Listing 2.166: Tuning the DevTools connection Time travel is not a replay of your code: the extension re-runs the recorded action list through the reducers from the initial state, so it works only if the reducers really are pure. A reducer that reads Date.now() gives different results on the second pass — a discrepancy that is itself a useful bug report. Warning: The extension can read and write your store from any page that enables it, so leave devTools off in production when the state holds tokens or personal data; false also strips the connection code.

2.18.7 The Provider

<Provider> is the bridge between the store and React: it puts the store on a React context so every Hook below it finds the same instance.

import { createRoot } from 'react-dom/client';

import { Provider } from 'react-redux';

import { store } from './app/store';

import App from './App';

createRoot(document.getElementById('root')).render(

<Provider store={store}><App /></Provider>

);

Listing 2.167: Mounting the store at the root Wrap the whole application, router included (Section 2.16), so route components can read the store too. A Hook rendered outside the Provider throws could not find react-redux context value, which almost always means a test rendered a component on its own; the fix is the same call, with a store built for that test — which is why configureStore is a plain function and not a singleton. Of the optional props, serverState is the one you will need: it takes the snapshot serialized during server rendering, so the first client render matches the HTML instead of tearing (Section 2.20.2). Under the hood: The Provider never re-renders on dispatch. Its context value is a fixed object holding the store and a subscription hub that each Hook subscribes to, which is why a store update does not repaint the tree the way a changed context value would (Section 2.5.5).

2.18.8 useSelector and useDispatch

useSelector(fn) subscribes a component to the store and returns fn(state); useDispatch() returns the store's dispatch. Between them they replace connect, with no wrapper component.

<style>body{font:15px/1.5 system-ui;margin:10px}

.b{border:1px solid #90a4ae;border-radius:6px;padding:6px 10px;margin:6px 0}</style>

<div id="root"></div>

<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>

<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0",

"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?external=react",

"@reduxjs/toolkit":"https://esm.sh/@reduxjs/toolkit@2.12.0?external=react",

"react-redux":"https://esm.sh/react-redux@9.3.0?external=react,react-dom"}}</script>

<script type="text/babel" data-type="module">

import React, { useRef } from 'react';

import { createRoot } from 'react-dom/client';

import { configureStore } from '@reduxjs/toolkit';

import { Provider, useSelector, useDispatch } from 'react-redux';

const cart = (s = { count: 0 }, a) => a.type === 'cart/added' ? { count: s.count + 1 } : s;

const user = (s = { name: 'guest' }, a) => a.type === 'user/named' ? { name: a.payload } : s;

const store = configureStore({ reducer: { cart, user } });

function CartPanel() {

const count = useSelector(s => s.cart.count); // subscribes to cart only

const dispatch = useDispatch();

const n = useRef(0); n.current += 1;

return <div className="b">items: <b>{count}</b> — rendered {n.current}x{' '}

<button onClick={() => dispatch({ type: 'cart/added' })}>Add item</button></div>;

}

function UserPanel() {

const name = useSelector(s => s.user.name); // subscribes to user only

const n = useRef(0); n.current += 1;

return <div className="b">user: <b>{name}</b> — rendered {n.current}x</div>;

}

createRoot(document.getElementById('root')).render(

<Provider store={store}><CartPanel /><UserPanel /></Provider>);

[100, 200, 300].forEach(t => setTimeout(() => document.querySelector('button').click(), t));

</script>

Listing 2.168: Two components, two subscriptions, two independent re-render counts Figure 2.60: Browser output of Listing 2.168Three dispatches re-rendered the cart four times and the user panel once: every selector runs after every action, but a component re-renders only when its selector's result changes by reference equality.That makes the one real pitfall obvious. useSelector(s => ({ a: s.a, b: s.b })) builds a new object every call, so it never equals the previous result and the component re-renders on every action in the application. Three fixes, best first: one useSelector call per value, as above; shallowEqual from react-redux as the second argument; or a memoized selector (Section 2.18.15).dispatch keeps a stable identity for the life of the store, so it is safe in dependency arrays. An inline arrow selector is fine, but a memoized selector must not be created inline: a new instance starts with an empty cache every render.

2.18.9 useStore

useStore() hands you the store object itself — the same one you passed to <Provider>. It does not subscribe, so a component that only calls useStore never re-renders when the state changes.

function ExportButton() {

const store = useStore(), dispatch = useDispatch(); // both from react-redux

const download = () => {

const snapshot = store.getState().cart; // read at click time, not at render time

dispatch({ type: 'cart/exported', payload: snapshot.items.length });

save(JSON.stringify(snapshot));

};

return <button onClick={download}>Export cart</button>;

}

Listing 2.169: Reading state without subscribing to it That is the shape of every legitimate use: an event handler needs a value the component does not render. Reading it through useStore keeps that component out of the subscriber list, so a cart with two hundred items does not re-render the button every time one changes, and store.getState() is always current where a value captured by useSelector is only as fresh as the last render. Calling store.getState() during render, by contrast, is a bug: React has not subscribed to that value, so the component renders stale data and never updates.

2.18.10 connect as Legacy

Before React Redux 7.1 shipped Hooks in June 2019, the only way to read the store was connect(), a higher-order component that injected state and dispatch as props. It still works, but you learn it only to read the codebase you inherited.

const mapStateToProps = (state, ownProps) => ({ count: state.cart.count, id: ownProps.id });

const mapDispatchToProps = { added: () => ({ type: 'cart/added' }) };

export default connect(mapStateToProps, mapDispatchToProps)(CartBadge);

Listing 2.170: A connected component: a wrapper computes props from state Reading such a component is mechanical translation: mapStateToProps becomes one useSelector call per value, and a mapDispatchToProps object becomes dispatch(actionCreator()). Converting one needs care in a single place — connect compared mapStateToProps output shallowly, so a component returning { a, b } needs shallowEqual once converted. connect earns its keep in one situation today: class components, which cannot call Hooks.

2.18.11 configureStore

configureStore is the Redux Toolkit replacement for createStore: it takes a named options object, calls combineReducers, applyMiddleware and compose for you, wires up the DevTools, and installs middleware that catches common mistakes.

import { configureStore } from '@reduxjs/toolkit';

import cartReducer from '../features/cart/cartSlice';

import filtersReducer from '../features/filters/filtersSlice';

export const store = configureStore({

reducer: { cart: cartReducer, filters: filtersReducer },

preloadedState: JSON.parse(sessionStorage.getItem('cart') || 'null') ?? undefined,

middleware: (getDefaultMiddleware) =>

getDefaultMiddleware({ thunk: { extraArgument: apiClient } }).concat(analytics),

devTools: process.env.NODE_ENV !== 'production',

});

Listing 2.171: A production store definition getDefaultMiddleware() installs redux-thunk in every build, plus three development-only checks: state mutated in or between dispatches, non-serializable values such as dates and promises in state or actions, and dispatch(increment) written without the call. The first two walk the whole state tree on every dispatch, which is why production strips them; on a large store, narrow them with immutableCheck: { ignoredPaths: [...] } or serializableCheck: { ignoredActions: [...] } rather than turning them off. The middleware option replaces the default list, so always take the callback form: .concat(x) puts x after the defaults and .prepend(x) before them, both preserving the types that make dispatch know about your thunks. Tip: Export type RootState = ReturnType<typeof store.getState> next to the store and build typed useAppSelector and useAppDispatch Hooks from it (Section 2.13.6).

2.18.12 createSlice

createSlice is where the boilerplate goes away. Give it a name, an initial state and an object of case reducers; it returns a reducer, one action creator per case (typed name/caseName), and — since Redux Toolkit 2.0 — selectors bound to the slice.

const cartSlice = createSlice({

name: 'cart', initialState: { lines: [] },

reducers: {

added(state, action) { // Immer: write as if it were mutable

const line = state.lines.find(l => l.id === action.payload.id);

if (line) line.qty += 1; else state.lines.push({ ...action.payload, qty: 1 });

},

removed: (s, a) => { s.lines = s.lines.filter(l => l.id !== a.payload); },

},

selectors: { selectTotal: s => s.lines.reduce((t, l) => t + l.price * l.qty, 0) },

});

const { added, removed } = cartSlice.actions, { selectTotal } = cartSlice.selectors;

const store = configureStore({ reducer: { cart: cartSlice.reducer } });

store.dispatch(added({ id: 1, name: 'Mug', price: 9 })); // { type: 'cart/added', payload }

const before = store.getState().cart.lines[0];

store.dispatch(added({ id: 2, name: 'Cap', price: 14 })); store.dispatch(removed(2));

console.log(store.getState().cart.lines, 'total', selectTotal(store.getState()));

console.log('Mug object reused?', before === store.getState().cart.lines[0]);

Listing 2.172: A cart slice: mutating syntax, generated actions, bound selectors

[ { id: 1, name: 'Mug', price: 9, qty: 1 } ] total 9

Mug object reused? true

Output of Listing 2.172 state.lines.push(...) is not a mutation of your state. Redux Toolkit wraps every case reducer in Immer, which hands the function a draft proxy, records the writes, and produces a new immutable object with structural sharing. The last line proves it: adding and removing the cap left the mug's object identical by reference, so a memoized row does not re-render. Immer's one rule: either mutate the draft or return a new value, never both, and never state = [], which does nothing — write state.lines = [].One more field matters: extraReducers handles actions owned by other slices — logout clearing the cart, or a thunk's fulfilled action (Section 2.18.13).

2.18.13 createAsyncThunk

Every hand-written data-loading thunk dispatches the same three actions: started, succeeded, failed. createAsyncThunk takes a type prefix and an async payload creator and generates all three — prefix/pending, prefix/fulfilled and prefix/rejected — exposed as thunk.pending and friends.

const fetchProducts = createAsyncThunk('products/fetch',

async (page, { rejectWithValue }) => {

if (page > 1) return rejectWithValue('no such page'); // an expected failure

await new Promise(r => setTimeout(r, 50)); // stands in for fetch()

return [{ id: 1, name: 'Mug' }]; // becomes action.payload

},

{ condition: (page, { getState }) => getState().products.status !== 'loading' });

const products = createSlice({

name: 'products', initialState: { status: 'idle', items: [], error: null }, reducers: {},

extraReducers: builder => builder

.addCase(fetchProducts.pending, s => { s.status = 'loading'; })

.addCase(fetchProducts.fulfilled, (s, a) => { s.status = 'ready'; s.items = a.payload; })

.addCase(fetchProducts.rejected, (s, a) => { s.status = 'failed'; s.error = a.payload; }),

});

const log = () => next => action => { console.log(action.type); return next(action); };

const store = configureStore({ reducer: { products: products.reducer },

middleware: g => g().concat(log) });

for (const page of [0, 2]) await store.dispatch(fetchProducts(page));

const { status, items, error } = store.getState().products;

console.log(status, '|', items, '|', error);

Listing 2.173: One thunk, three generated actions, one slice reacting to them

products/fetch/pending

products/fetch/fulfilled

products/fetch/pending

products/fetch/rejected

failed | [ { id: 1, name: 'Mug' } ] | no such page

Output of Listing 2.173 The second argument, the thunkAPI object, carries most of the value. rejectWithValue(x) turns a handled failure — a 404, a validation error — into rejected with action.payload set to x, where a thrown error gives you only a serialized message under action.error. Its signal is an AbortSignal tied to the promise's .abort(), so passing it to fetch makes cancellation work end to end. The condition option dedupes: return false and nothing is dispatched, not even pending.store.dispatch(thunk()) returns a promise that always fulfills, with the fulfilled or the rejected action, so await in a component will not throw. Chain .unwrap() when you want the error: it resolves with the payload or throws, which fits a try/catch. Note: builder.addMatcher(isPending(fetchProducts, fetchUser), ...) matches many thunks at once, turning a dozen addCase lines into one shared loading flag.

2.18.14 createEntityAdapter

A list of records in an array makes every update a scan. createEntityAdapter normalizes the collection to { ids: [], entities: {} } and generates the CRUD reducers and selectors for it.

const adapter = createEntityAdapter({

sortComparer: (a, b) => a.name.localeCompare(b.name), // keeps ids sorted

});

const products = createSlice({

name: 'products', initialState: adapter.getInitialState({ status: 'idle' }),

reducers: { loaded: adapter.setAll, added: adapter.addOne, // CRUD reducers,

renamed: adapter.updateOne, dropped: adapter.removeOne }, // used directly

});

const { loaded, added, renamed, dropped } = products.actions;

const store = configureStore({ reducer: { products: products.reducer } });

const selectors = adapter.getSelectors(state => state.products);

store.dispatch(loaded([{ id: 2, name: 'Mug' }, { id: 7, name: 'Apron' }]));

store.dispatch(added({ id: 4, name: 'Cap' }));

store.dispatch(renamed({ id: 2, changes: { name: 'Zinc mug' } })); store.dispatch(dropped(7));

const s = store.getState();

console.log(s.products.ids, s.products.entities);

console.log(selectors.selectAll(s).map(p => p.name), selectors.selectTotal(s));

Listing 2.174: Normalized products with generated reducers and selectors

[ 4, 2 ] { '2': { id: 2, name: 'Zinc mug' }, '4': { id: 4, name: 'Cap' } }

[ 'Cap', 'Zinc mug' ] 2

Output of Listing 2.174 Reading one record is now a property lookup instead of a scan, and — the real payoff — updating one record leaves every other record's object identity intact, so a memoized row does not re-render because its neighbor changed. getSelectors(rootSelector) returns selectAll, selectById, selectIds, selectEntities and selectTotal, already memoized.The generated reducers come in families, each with a One and a Many form: add ignores an id already present, set replaces, upsert shallow-merges, update takes { id, changes }, and remove deletes. One option you will need early is selectId (e => e._id), for MongoDB documents keyed on _id (Chapter 4).

2.18.15 createSelector

A selector that derives a value — a total, a filtered list, a sorted view — returns a new array or object on every action, defeating useSelector's reference check. createSelector, re-exported by Redux Toolkit from Reselect, memoizes it: input selectors extract the raw pieces, and the result function runs only when one of those pieces changes identity.

import { createSelector } from '@reduxjs/toolkit';

const state = { lines: [{ name: 'Mug', price: 9 }, { name: 'Cap', price: 14 }], vat: 0.5 };

let runs = 0;

const selectLines = s => s.lines, selectVat = s => s.vat; // cheap, no allocation

const selectTotal = createSelector([selectLines, selectVat], (lines, vat) => {

runs += 1; // count recomputations

return lines.reduce((t, l) => t + l.price, 0) * (1 + vat);

});

console.log(selectTotal(state), selectTotal(state), 'result fn ran', runs, 'time(s)');

console.log(selectTotal({ ...state, other: 1 }), 'still', runs); // inputs unchanged

console.log(selectTotal({ ...state, vat: 0 }), 'now', runs); // one input changed

Listing 2.175: Memoized derivation, counted

34.5 34.5 result fn ran 1 time(s)

34.5 still 1

23 now 2

Output of Listing 2.175 The second line is the point: a brand-new root state object did not trigger a recomputation, because state.lines and state.vat were still the same references. Keep input selectors dumb — property access only, never .map or an object literal — or every call produces fresh inputs and nothing is ever cached.Reselect 5, inside Redux Toolkit 2, changed the default memoizer from lruMemoize, whose cache held one result, to weakMapMemoize, which keys results on argument identity. That killed the classic bug where two components calling selectItemById(state, 1) and selectItemById(state, 2) in alternation recomputed on every render, so the old selector-factory-per-component fix is obsolete.

2.18.16 Listener Middleware

store.subscribe fires on every action and tells you nothing about which one. The listener middleware is the targeted version, and Redux Toolkit's answer to redux-saga: effects that run only for the actions they name, after the reducer, with an API for cancellation, debouncing and waiting.

const cart = createSlice({ name: 'cart', initialState: 0, reducers: { added: s => s + 1 } });

const listener = createListenerMiddleware();

listener.startListening({

actionCreator: cart.actions.added,

effect: async (action, api) => {

console.log('fired for', action.type, '- state was', api.getOriginalState().cart);

api.cancelActiveListeners(); // drop the run started by the previous action

await api.delay(50); // debounce: only the last run survives

console.log('saving cart, items =', api.getState().cart);

},

});

const store = configureStore({ reducer: { cart: cart.reducer },

middleware: g => g().prepend(listener.middleware) });

for (let i = 0; i < 3; i += 1) store.dispatch(cart.actions.added());

Listing 2.176: A debounced save, driven by an action

fired for cart/added - state was 0

fired for cart/added - state was 1

fired for cart/added - state was 2

saving cart, items = 3

Output of Listing 2.176 Three dispatches, three effect runs, one save: cancelActiveListeners() aborts the earlier runs, parked in delay, so only the last reaches the network. Writing that with useEffect and a timer ref would need a component that has no business knowing about persistence.Pick the trigger with exactly one of four keys: type, actionCreator (the usual choice, and typed), matcher (isAnyOf(a, b) or a thunk matcher from Section 2.18.13), or predicate, which compares the previous and current state instead of looking at the action.

2.18.17 Queries with RTK Query

RTK Query is a caching data-fetching layer built into Redux Toolkit, imported from @reduxjs/toolkit/query/react. You describe endpoints once; it generates a slice, a middleware and a Hook per endpoint, and handles deduplication, caching, loading flags, refetching and invalidation — replacing everything you would otherwise hand-write for server data.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const catalogApi = createApi({

reducerPath: 'catalogApi', // where the cache lives

baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com/' }),

tagTypes: ['Product'],

endpoints: (build) => ({

getProducts: build.query({

query: (page = 1) => `products?page=${page}`, // the argument is the key

providesTags: (result = []) => result.map(p => ({ type: 'Product', id: p.id })),

}),

}),

});

export const { useGetProductsQuery } = catalogApi; // generated, one per endpoint

export const store = configureStore({

reducer: { [catalogApi.reducerPath]: catalogApi.reducer },

middleware: (g) => g().concat(catalogApi.middleware), // required: drives the cache

});

function Products({ page }) {

const { data, error, isLoading, isFetching } = useGetProductsQuery(page);

if (isLoading) return <p>Loading...</p>; // first load for this hook

if (error) return <p role="alert">Request failed</p>;

return <ul style={{ opacity: isFetching ? 0.5 : 1 }}> {/* background refetch */}

{data.map(p => <li key={p.id}>{p.name}</li>)}</ul>;

}

Listing 2.177: An API slice and the Hook it generates Two flags, two meanings. isLoading is true only while there is no data at all for this Hook; isFetching is true whenever a request is in flight, including the refetch after a page change. Dimming the old page instead of replacing it with a spinner is what makes pagination feel instant.The cache is keyed by endpoint plus serialized argument, so two components calling useGetProductsQuery(1) share one request and one entry, while 2 starts a second entry. A query can be held back with { skip: !id }, deferred with useLazyGetProductsQuery(), or narrowed with selectFromResult so the component re-renders only when the part it displays changes. Tip: queryFn replaces query when a request does not fit fetchBaseQuery — a GraphQL client, or several requests combined into one entry. Return { data } or { error } and the caching is unchanged.

2.18.18 Mutations with RTK Query

A mutation writes to the server. build.mutation describes the request, and the generated Hook returns a trigger function plus a result object — it never fires on render. What happens afterwards is the interesting part: invalidatesTags tells RTK Query which cached queries are now wrong, and every mounted component subscribed to them refetches on its own.

addProduct: build.mutation({

query: (body) => ({ url: 'products', method: 'POST', body }),

invalidatesTags: [{ type: 'Product', id: 'LIST' }], // refetches the list query

}),

renameProduct: build.mutation({

query: ({ id, name }) => ({ url: `products/${id}`, method: 'PATCH', body: { name } }),

async onQueryStarted({ id, name }, { dispatch, queryFulfilled }) {

const patch = dispatch(catalogApi.util.updateQueryData('getProducts', 1, draft => {

const item = draft.find(p => p.id === id);

if (item) item.name = name; // applied before the server replies

}));

try { await queryFulfilled; } catch { patch.undo(); }

},

}),

function AddProduct() {

const [addProduct, { isLoading, error }] = useAddProductMutation();

const submit = (e) => { e.preventDefault(); addProduct({ name: e.target.name.value }); };

return <form onSubmit={submit}>

<input name="name" /><button disabled={isLoading}>Add</button>

{error && <p role="alert">Could not save</p>}</form>;

}

Listing 2.178: A mutation, tag invalidation, and an optimistic update Tags are the whole invalidation model. A query declares what it holds with providesTags; a mutation declares what it breaks with invalidatesTags. Invalidating { type: 'Product', id: 'LIST' } — the conventional sentinel for "the collection" — refetches the list without touching cached detail entries, while { type: 'Product', id: 7 } refetches only that product. Both accept a function of (result, error, arg), so a failed mutation invalidates nothing.onQueryStarted runs when the mutation begins and gives you the optimistic path: util.updateQueryData writes into a cached query through Immer and returns a patch with .undo(), so a rejected request rolls the UI back. Use it where the outcome is nearly certain — a rename, a toggle, a like — and stay with tag invalidation elsewhere; a wrong optimistic update is worse than a slow one.

2.18.19 Cache Lifetime and Refetching

Every mounted Hook is a subscriber to one cache entry, and RTK Query counts them. The first subscriber triggers the request; later ones get cached data with no request; and when the count drops to zero the entry survives for keepUnusedDataFor seconds — 60 by default — before it is deleted, so a remount inside that window renders the old data immediately.Figure 2.61: The life of one cache entry, from first subscriber to evictionCached does not mean fresh, so RTK Query offers several ways to refetch. refetchOnMountOrArgChange: true refetches whenever a new subscriber mounts, or takes a number of seconds meaning "only if the entry is older than this". refetchOnFocus and refetchOnReconnect react to focus and to the network coming back; both require setupListeners(store.dispatch). pollingInterval: 30000 turns a query into a live view, the result's refetch() forces one immediately, and dispatch(api.util.resetApiState()) wipes everything on logout.

export const catalogApi = createApi({

baseQuery: fetchBaseQuery({ baseUrl: 'https://api.example.com/' }),

keepUnusedDataFor: 300, // five minutes for this API

refetchOnMountOrArgChange: 30, // stale after 30 s

refetchOnFocus: true, // needs setupListeners(store.dispatch)

endpoints: (build) => ({ // per-endpoint values override the API's

getRates: build.query({ query: () => 'rates', keepUnusedDataFor: 5 }),

}),

});

Listing 2.179: Global cache and refetch policy Tune the numbers to how fast the data actually changes: a currency rate is stale in seconds, a product catalog in hours, a country list effectively never.