MENU
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.
Figure 2.58: One dispatch, one pass through the store, one
re-render2.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();
state is now 1
state is now 11
state is now 11
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, {}));
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 });
-> counter/addedBy before: 0
<- counter/addedBy after: 7
-> counter/addedBy before: 7
<- counter/addedBy after: 7
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);
second dispatch -> ignored
first dispatch -> { id: 'A-17', total: 27 }
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
},
});
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>
);
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>
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>;
}
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);
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',
});
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]);
[ { id: 1, name: 'Mug', price: 9, qty: 1 } ] total 9
Mug object reused? true
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);
products/fetch/pending
products/fetch/fulfilled
products/fetch/pending
products/fetch/rejected
failed | [ { id: 1, name: 'Mug' } ] | no such page
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));
[ 4, 2 ] { '2': { id: 2, name: 'Zinc mug' }, '4': { id: 4, name: 'Cap' } }
[ 'Cap', 'Zinc mug' ] 2
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
34.5 34.5 result fn ran 1 time(s)
34.5 still 1
23 now 2
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());
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
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>;
}
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>;
}
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 }),
}),
});