Module 6: Global State and App Architecture
Redux Toolkit
The Redux mental model, how slices and Immer remove the old boilerplate, selectors, async thunks, and when Redux is a good choice.
Redux has a reputation for boilerplate, and the reputation was earned. The original style needed separate action constants, action creators, switch statements and careful immutable updates. Redux Toolkit (RTK) is the officially recommended way to write Redux now, and it cuts most of that away. If you've seen old Redux code and hated it, this is a different experience.
Why Redux still exists#
Redux is a single store that holds your app's global state, plus strict rules about how that state can change. The strictness is the point. Every change is described by an action, goes through a pure reducer, and can be logged and replayed. On a big team and a big codebase, "where did this value change?" has exactly one place to look, and the Redux DevTools can show you the full history.
You don't need it for a small app. You'll meet it in many existing codebases, in job postings, and in interviews, so it's worth knowing well.
The flow#
component
│ dispatch(addToCart(product)) action = { type: "cart/addToCart", payload: product }
▼
store ──► reducer(state, action) ──► new state
▲ │
│ subscribers notified │
└──────── useSelector re-runs ◄─────────┘
│
▼
component re-renders if the selected value changed
It's useReducer with a global scope and good tooling.
Setup#
npm install @reduxjs/toolkit react-redux
A slice#
A slice bundles the state, the reducers and the generated actions for one feature.
// src/features/cart/cartSlice.js
import { createSlice } from "@reduxjs/toolkit";
const initialState = { items: [] };
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addItem(state, action) {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
existing.qty += 1; // looks like mutation, and it's fine here
} else {
state.items.push({ ...action.payload, qty: 1 });
}
},
removeItem(state, action) {
state.items = state.items.filter((i) => i.id !== action.payload);
},
clearCart() {
return initialState;
},
},
});
export const { addItem, removeItem, clearCart } = cartSlice.actions;
export default cartSlice.reducer;
// selectors live next to the slice
export const selectCartItems = (state) => state.cart.items;
export const selectCartCount = (state) =>
state.cart.items.reduce((sum, i) => sum + i.qty, 0);
You wrote existing.qty += 1 and state.items.push(...), which would be a bug in a normal reducer. Inside createSlice it's safe, because RTK uses a library called Immer.
How Immer works#
Immer hands your reducer a draft, a proxy wrapped around the real state. When you "mutate" the draft, Immer records what you did. When the function finishes, it creates a new immutable state object with only those changes applied, reusing every untouched part of the old state.
state (frozen) draft (proxy) next state
{ items: [A, B] } ──► draft.items.push(C) ──► { items: [A, B, C] }
(recorded, not applied) old state unchanged
Two rules inside an Immer reducer: either mutate the draft or return a new value, never both in one call. And the mutation magic only works inside createSlice and createReducer. In ordinary code it's still plain mutation.
The store#
// src/app/store.js
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "../features/cart/cartSlice.js";
export const store = configureStore({
reducer: {
cart: cartReducer,
// other slices here
},
});
configureStore sets up Redux DevTools, adds the thunk middleware and, in development, checks for accidental mutations and non-serializable values in state.
Providing it#
// main.jsx
import { Provider } from "react-redux";
import { store } from "./app/store.js";
createRoot(document.getElementById("root")).render(
<Provider store={store}>
<App />
</Provider>
);
Using it in components#
import { useDispatch, useSelector } from "react-redux";
import { addItem, removeItem, selectCartCount, selectCartItems } from "./cartSlice.js";
function CartBadge() {
const count = useSelector(selectCartCount);
return <span>Cart ({count})</span>;
}
function CartList() {
const items = useSelector(selectCartItems);
const dispatch = useDispatch();
return (
<ul>
{items.map((i) => (
<li key={i.id}>
{i.name} × {i.qty}
<button onClick={() => dispatch(removeItem(i.id))}>Remove</button>
</li>
))}
</ul>
);
}
function ProductCard({ product }) {
const dispatch = useDispatch();
return <button onClick={() => dispatch(addItem(product))}>Add {product.name}</button>;
}
useSelector takes a function that picks what the component needs from the store. The component re-renders only when the selected value changes (compared with strict equality). CartBadge ignores everything except the count.
Selectors are the thing to build habits around. Putting derived calculations into selectors keeps components simple and lets you change the state shape later by touching one place.
Don't select too much#
const state = useSelector((s) => s); // re-renders on any store change
const cart = useSelector((s) => s.cart); // re-renders on any cart change
const count = useSelector(selectCartCount); // re-renders only when count changes
Also be careful about selectors that return a new array or object each time (s.items.map(...) inside a selector). They'll look "changed" on every call and cause extra renders. For derived collections, RTK re-exports createSelector from Reselect, which memoizes.
Async logic with thunks#
A thunk is a function you dispatch that can do async work and then dispatch ordinary actions.
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
export const fetchProducts = createAsyncThunk("products/fetch", async () => {
const res = await fetch("/api/products");
if (!res.ok) throw new Error("Failed to load");
return res.json();
});
const productsSlice = createSlice({
name: "products",
initialState: { items: [], status: "idle", error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => { state.status = "loading"; })
.addCase(fetchProducts.fulfilled, (state, action) => {
state.status = "succeeded";
state.items = action.payload;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.status = "failed";
state.error = action.error.message;
});
},
});
That works, but look at how much code you wrote to fetch a list: three statuses, three cases, plus you still have no caching, no refetching and no deduplication. This is the reason many teams today use RTK Query (part of Redux Toolkit) or TanStack Query for server data, and keep slices for client state only.
When to pick Redux Toolkit#
Good fit:
- a large app with many developers who benefit from enforced structure
- complex client-side state with lots of interdependent updates
- you want time-travel debugging and a log of every state change
- the project already uses Redux (very common)
Probably not needed:
- a small app, a prototype, or mostly server data
- when
useStateplus a little context covers it
Where people get burned#
- Mutating state outside
createSliceand expecting Immer to protect you. - Both mutating the draft and returning a value in the same reducer.
- Storing non-serializable things (functions, class instances,
Dateobjects) in the store. Store timestamps as numbers or ISO strings. useSelector((s) => s)and then wondering why everything re-renders.- Copying server data into slices and hand-rolling refetch logic.
- Putting every
useStatein the store.
Practice#
- Build a cart with
addItem,removeItemandclearCart, and a header badge showing the total quantity. Use selectors. - Open the Redux DevTools browser extension and watch each action appear. Click on a past action to see the state at that moment.
- Add a
favoritesslice and a heart button on each product card. - Try to mutate state outside a reducer (
store.getState().cart.items.push(x)) and see what RTK's development checks say.