Chapter 21 — State Management with Redux Toolkit
📖 Definitions
- Redux — predictable state container, single store, reducers update state via dispatched actions.
- Redux Toolkit (RTK) — the official, opinionated wrapper that eliminates most boilerplate.
- Thunk — middleware allowing actions to return a function (used for async logic).
- RTK Query — RTK's built-in data-fetching layer (similar to TanStack Query).
🔍 Core Concepts
| Term | Meaning |
|---|---|
| Store | Single object holding the whole state tree |
| Slice | Feature-scoped reducer + actions (createSlice) |
| Action | { type, payload } describing what happened |
| Reducer | Pure function (state, action) => newState |
| Selector | Function reading derived data from the store |
| Thunk | Async action (createAsyncThunk) |
💻 Code Example — Setting Up the Store
// store.js
import { configureStore } from "@reduxjs/toolkit";
import userReducer from "./userSlice";
import cartReducer from "./cartSlice";
export const store = configureStore({
reducer: {
user: userReducer,
cart: cartReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;// index.jsx
import { Provider } from "react-redux";
import { store } from "./store";
<Provider store={store}>
<App />
</Provider>💻 Code Example — Creating a Slice
// cartSlice.js
import { createSlice } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: { items: [], total: 0 },
reducers: {
addItem(state, action) {
state.items.push(action.payload); // ✅ immer lets you "mutate"
state.total += action.payload.price;
},
removeItem(state, action) {
const idx = state.items.findIndex(i => i.id === action.payload);
if (idx > -1) {
state.total -= state.items[idx].price;
state.items.splice(idx, 1);
}
},
clear(state) {
state.items = [];
state.total = 0;
},
},
});
export const { addItem, removeItem, clear } = cartSlice.actions;
export default cartSlice.reducer;RTK uses Immer internally, so you can write "mutating" code — it's converted to immutable updates under the hood.
💻 Code Example — Consuming State and Dispatching
import { useSelector, useDispatch } from "react-redux";
import { addItem, removeItem } from "./cartSlice";
function Cart() {
const { items, total } = useSelector(state => state.cart);
const dispatch = useDispatch();
return (
<div>
<h3>Cart ({items.length} items) — ₹{total}</h3>
<ul>
{items.map(i => (
<li key={i.id}>
{i.name} — ₹{i.price}
<button onClick={() => dispatch(removeItem(i.id))}>×</button>
</li>
))}
</ul>
<button onClick={() => dispatch(addItem({ id: 99, name: "Book", price: 250 }))}>
Add Book
</button>
</div>
);
}💻 Code Example — createAsyncThunk
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
export const fetchUser = createAsyncThunk(
"user/fetch",
async (id, { rejectWithValue }) => {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("Failed");
return await res.json();
} catch (err) {
return rejectWithValue(err.message);
}
}
);
const userSlice = createSlice({
name: "user",
initialState: { data: null, status: "idle", error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (s) => { s.status = "loading"; s.error = null; })
.addCase(fetchUser.fulfilled, (s, a) => { s.status = "succeeded"; s.data = a.payload; })
.addCase(fetchUser.rejected, (s, a) => { s.status = "failed"; s.error = a.payload; });
},
});
export default userSlice.reducer;Usage:
const dispatch = useDispatch();
useEffect(() => { dispatch(fetchUser(1)); }, [dispatch]);
const { data, status, error } = useSelector(s => s.user);💻 Code Example — Selectors (Reusable + Memoized)
import { createSelector } from "@reduxjs/toolkit";
const selectCartItems = (state) => state.cart.items;
export const selectPaidItems = createSelector(
[selectCartItems],
(items) => items.filter(i => i.paid)
);
export const selectCartTotal = createSelector(
[selectCartItems],
(items) => items.reduce((sum, i) => sum + i.price, 0)
);
// In component
const paid = useSelector(selectPaidItems); // memoized — recomputes only when items change💻 Code Example — RTK Query (Built-in API Layer)
// api.js
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const api = createApi({
reducerPath: "api",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["User"],
endpoints: (builder) => ({
getUser: builder.query({
query: (id) => `/users/${id}`,
providesTags: (result, error, id) => [{ type: "User", id }],
}),
updateUser: builder.mutation({
query: ({ id, ...patch }) => ({ url: `/users/${id}`, method: "PATCH", body: patch }),
invalidatesTags: (r, e, { id }) => [{ type: "User", id }],
}),
}),
});
export const { useGetUserQuery, useUpdateUserMutation } = api;// Usage
function UserCard({ id }) {
const { data, isLoading } = useGetUserQuery(id);
const [updateUser] = useUpdateUserMutation();
if (isLoading) return <Spinner />;
return (
<>
<h2>{data.name}</h2>
<button onClick={() => updateUser({ id, name: "Renamed" })}>Rename</button>
</>
);
}RTK Query handles caching, refetching, dedup, optimistic updates — much like TanStack Query but integrated into Redux.
📊 Context API vs Redux
| Context | Redux (RTK) | |
|---|---|---|
| Best for | Theming, auth user, locale | Complex, frequently-updated app state |
| Devtools | None | Time-travel debugger |
| Middleware | None built-in | Thunks, listener, sagas |
| Boilerplate | Minimal | Moderate (RTK reduces it) |
| Performance | Re-renders all consumers | Selector-based, granular |
| Async helpers | DIY | createAsyncThunk, RTK Query |
🎯 Likely Interview Questions
- Why use Redux over Context?
- What is a slice in RTK?
- Why does RTK let you "mutate" state? — Immer wraps reducers to produce immutable updates.
- What is a thunk?
- What is
createSelector? — Memoized selector that only recomputes when its inputs change. - How is RTK Query different from TanStack Query? — RTK Query is part of Redux store; better when you already use Redux. TanStack Query is standalone and often preferred for greenfield projects.