Redux is a state container. You have already built one: a reducer inside a context provider is the same architecture — one state object, changed only by dispatching actions to a pure function.
So the honest question is not "how do I use Redux", it is "what does Redux give me that the reducer-in-a-context does not, and is that worth a dependency". This post answers both, using an application that made the decision both ways on purpose: the pizza app runs its customer pages on Context and its admin dashboard on Redux.
Redux Toolkit, not classic Redux
If you have seen Redux before and remember action-type constants, hand-written action creators,
applyMiddleware, combineReducers and a great deal of ceremony — that is
classic Redux and nobody writes it any more. Redux Toolkit is the official way to use
Redux, and it is a different experience.
npm install @reduxjs/toolkit react-reduxA slice
A slice is the initial state, the reducers, and the action creators, generated together:
interface OrdersState {
items: Order[];
page: number;
totalPages: number;
/** Which statuses to show. Empty means everything. */
statusFilter: OrderStatus[];
loading: boolean;
error: string | null;
}
const initialState: OrdersState = {
items: [], page: 0, totalPages: 1, statusFilter: [], loading: false, error: null,
};
const ordersSlice = createSlice({
name: 'orders',
initialState,
reducers: {
pageChanged(state, action: PayloadAction<number>) {
state.page = action.payload;
},
/** Toggle one status in or out of the filter. */
statusFilterToggled(state, action: PayloadAction<OrderStatus>) {
const status = action.payload;
state.statusFilter = state.statusFilter.includes(status)
? state.statusFilter.filter((s) => s !== status)
: [...state.statusFilter, status];
},
statusFilterCleared(state) {
state.statusFilter = [];
},
},
});
export const { pageChanged, statusFilterToggled, statusFilterCleared } = ordersSlice.actions;
export const ordersReducer = ordersSlice.reducer;createSlice generated pageChanged, statusFilterToggled and
statusFilterCleared as action creators, along with their type strings
('orders/pageChanged'). In classic Redux you wrote all three by hand and kept them in
sync.
That mutation is not a mutation
state.page = action.payload would be a bug in a plain React reducer. It is correct
here because Redux Toolkit runs every reducer through
Immer, which records writes against a
draft and produces a new immutable object from them. The rule that state is never mutated still
holds — Immer is doing the copying for you.
This is the single biggest ergonomic difference from a hand-written reducer. Compare updating one item in a list:
// Plain React reducer — you write the copy.
return {
...state,
items: state.items.map((item) =>
item.lineId === action.payload.lineId ? { ...item, quantity: action.payload.quantity } : item,
),
};
// Redux Toolkit — Immer writes it for you.
const item = state.items.find((i) => i.id === action.payload.id);
if (item) item.quantity = action.payload.quantity;Deeply nested state is where this stops being cosmetic and starts saving real pain.
Async: createAsyncThunk
A reducer must be pure, so it cannot make a request. A thunk is an action creator that returns a function instead of an object, which gives it somewhere to do async work:
export const fetchOrders = createAsyncThunk(
'orders/fetch',
async (page: number) => adminApi.listOrders(page, 20),
);
export const changeOrderStatus = createAsyncThunk(
'orders/changeStatus',
async ({ id, status }: { id: UUID; status: OrderStatus }) =>
adminApi.updateOrderStatus(id, status),
);createAsyncThunk dispatches three actions on your behalf —
pending, fulfilled and rejected — which you handle in
extraReducers:
extraReducers: (builder) => {
builder
.addCase(fetchOrders.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchOrders.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload.content;
state.totalPages = action.payload.totalPages;
})
.addCase(fetchOrders.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message ?? 'Could not load orders.';
})
/*
* Patch the one row that changed rather than refetching the page. The server returns the
* updated order, so the list can be kept in step without a second round trip.
*/
.addCase(changeOrderStatus.fulfilled, (state, action) => {
const updated = action.payload;
state.items = state.items.map((order) => (order.id === updated.id ? updated : order));
});
},That loading/error/data triple is exactly what MenuContext writes out by hand on the
customer side. Here it comes with the thunk.
extraReducers also lets a slice respond to actions it did not define, which is how
two slices react to the same event without importing each other.
The store
export const store = configureStore({
reducer: {
catalog: catalogReducer,
orders: ordersReducer,
reports: reportsReducer,
users: usersReducer,
},
});
/*
* Types derived FROM the store rather than declared alongside it, so they can never drift out of
* step with the reducers above. Add a slice and RootState grows automatically.
*/
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();configureStore already wires in redux-thunk, the Redux DevTools connection, and — in
development only — checks that shout if you mutate state outside a reducer or put something
non-serialisable (a Date, a promise, a class instance) into the store. Classic Redux
needed applyMiddleware and composeEnhancers by hand for the same result.
Those last two lines are worth copying verbatim into any project. Components import
useAppSelector and useAppDispatch instead of the raw hooks, so state is
typed with no generic at the call site and dispatching a thunk typechecks — the plain
Dispatch type does not know thunks are dispatchable.
Using it in a component
export default function AdminOrdersPage() {
const { showToast } = useToast();
/*
* REDUX CONCEPT: useSelector subscribes; useDispatch does not.
*
* This component re-renders when the slice it selects changes, and NOT when an unrelated slice
* does. Compare the six useState calls this replaced — the same state, but scattered across the
* component and gone the moment it unmounted.
*/
const dispatch = useAppDispatch();
const { items: orders, page, totalPages, loading, error } = useAppSelector(selectOrdersState);
useEffect(() => {
void dispatch(fetchOrders(page));
}, [dispatch, page]);
async function changeStatus(order: Order, status: OrderStatus) {
/*
* `unwrap()` re-throws the thunk's rejection so this can be written as a normal try/catch.
* Without it, dispatch RESOLVES even for a rejected thunk — it hands back the action object —
* and the catch below would never run, silently reporting failures as successes.
*/
try {
await dispatch(changeOrderStatus({ id: order.id, status })).unwrap();
showToast(`Order moved to ${status.replace('_', ' ').toLowerCase()}`);
} catch (err) {
showToast(err instanceof Error ? err.message : 'Could not update the order', 'danger');
}
}
/* … */
}That .unwrap() note is worth remembering. dispatch(thunk()) returns a
promise that resolves even when the thunk failed — it hands back the action object,
rejection and all. A try/catch without unwrap() never fires, and every
failure is reported to the user as a success.
Selectors
A selector is a plain function from state to the part a component wants:
export const selectOrdersState = (state: { orders: OrdersState }) => state.orders;
export function selectVisibleOrders(state: { orders: OrdersState }): Order[] {
const { items, statusFilter } = state.orders;
if (statusFilter.length === 0) return items;
return items.filter((order) => statusFilter.includes(order.status));
}Two reasons to write them rather than reaching into state inline. The state shape stays
private to the slice file, so renaming a field touches one file. And derived data is derived rather
than stored — selectVisibleOrders filters on read, so a filtered list can never go stale
against the list it came from. Same reasoning as
useMemo, without a store.
One caveat: a selector that builds a new array or object returns a new reference every call, which
defeats useSelector's reference check and re-renders every time. For cheap filters that
is fine; for expensive ones, memoise with createSelector from Redux Toolkit.
Where the Provider goes — and why it matters here
Most tutorials put <Provider store={store}> in main.tsx around the
whole app. This one deliberately does not:
export default function AdminLayout() {
return (
<Provider store={store}>
<Container className="py-4">
<h1 className="h3 fw-bold mb-1">Admin</h1>
{/* … tabs … */}
<Outlet />
</Container>
</Provider>
);
}The provider wraps only the admin subtree, and that placement does two jobs.
It enforces the architecture. Customer-facing pages run on Context and cannot reach this store even by accident. "Redux for admin, Context for customers" is guaranteed by the component tree rather than by everyone remembering the convention.
It keeps Redux out of the main bundle. AdminLayout is behind
lazy(), so Redux, the four slices and their
thunks are all pulled into the admin chunk. The customers who only ever order a pizza download none
of it.
What Redux actually bought, in this app
Three concrete things, none of which are "it is the standard".
1. State that outlives the component
This is the strongest one. The reports dashboard used to hold its data in
useState, so switching to the Products tab and back threw the report away and refetched
it — a visible spinner, every time:
interface ReportsState {
/** Keyed by the day-range, because 7/30/90 are three different reports, not one changing one. */
byRange: Record<number, ReportDashboard>;
days: number;
loading: boolean;
error: string | null;
}.addCase(fetchDashboard.fulfilled, (state, action) => {
state.loading = false;
// action.meta.arg is the argument the thunk was dispatched with — here, the range. Without
// it a slow 90-day response could land after the user switched to 7 and be filed wrongly.
state.byRange[action.meta.arg] = action.payload;
})Store state is not owned by a component, so unmounting the page does not discard it and the tab comes back instantly. You can build the same cache with a context provider mounted above the tabs — but note that you would then be building a cache, which is precisely the work Redux is saving you.
action.meta.arg is a small gem: it is the argument the thunk was dispatched with, so
a slow 90-day response arriving after the user switched to 7 days is filed under 90 rather than
overwriting the visible report.
2. Cross-screen consistency
Products, toppings and crusts share one slice, even though each has its own tab:
/*
* REDUX CONCEPT: slice boundaries follow the DOMAIN, not the screen
*
* Products, toppings and crusts get one slice between them, even though they have a tab each.
* They are one thing — the menu — and they change together: adding a topping should be visible to
* the product editor without either page knowing the other exists.
*/3. DevTools
The Redux DevTools extension gives you every dispatched action with its payload, a diff of the state it produced, and the ability to step backwards through them. When a report disagrees with the orders table, that log is the fastest way to find out why. React DevTools has nothing equivalent — you can inspect current state, not the sequence that produced it.
Whether that is worth a dependency depends entirely on how often you are debugging state, which is a question about your application, not about Redux.
What it cost
- Two dependencies, and Redux's own dev-mode serialisability checks to satisfy.
- Indirection. Following a click now means component → action → thunk → reducer → selector → component. Every hop is justified; there are still six of them.
- Rejections must be serialisable. This one caught the app out: Redux Toolkit
runs a thrown Error through
miniSerializeError, which keepsname,messageandstackand discards everything else — including the structured API body needed to put "already registered" underneath the right input. Failures have to be flattened at the edge:
/** A request failure, flattened into something safe to put in a Redux action. */
export interface ApiFailure {
message: string;
/** Empty unless the server returned field-level validation errors. */
fieldErrors: Record<string, string>;
}
export function toApiFailure(err: unknown, fallback: string): ApiFailure {
if (err instanceof ApiError) {
return { message: err.message, fieldErrors: err.fieldErrors() };
}
return { message: err instanceof Error ? err.message : fallback, fieldErrors: {} };
}That file exists purely because Redux requires serialisable actions. It is not a large price, but it is a real one, and it is the kind of thing tutorials never mention.
Context + useReducer, or Redux?
| Context + useReducer | Redux Toolkit | |
|---|---|---|
| Dependencies | none | two |
| Immutable updates | you write the spreads | Immer writes them |
| Async | an effect you write | createAsyncThunk |
| Survives unmount | only if the provider is above it | always |
| Re-render control | split contexts by hand | per-selector, automatic |
| Time-travel debugging | no | yes |
| Code to read | one file | slice + store + selectors |
Start with Context and useReducer. It is free, it is already in React, and for a great many applications it is where the story ends — this app's cart, auth, menu and toasts have never needed anything more.
Reach for Redux when state must outlive the components that show it, when several screens genuinely share and mutate the same data, when async flows get complex enough that you want them modelled rather than improvised, or when you are debugging state often enough that DevTools would pay for itself.
Do not reach for it because the app is "big", or because a job advert mentioned it. Size is not the signal; shared, long-lived, asynchronously-updated state is.
And consider the other answers
A lot of what people historically put in Redux was server data, and server data has better tools now. TanStack Query or Redux Toolkit's own RTK Query handle caching, deduplication, retries and revalidation properly, and between them remove most of the reason a store existed. For genuinely client-side state that is not worth a store, Zustand is a much smaller option.
Redux is a good tool that spent a decade being used for problems it was not the best answer to. Knowing when not to use it is most of knowing how to use it.