Topic 15.4
Redux with Redux Toolkit
In one line
Redux keeps app state in one store that changes only when you dispatch an action, which a pure reducer turns into the next state; Redux Toolkit is the official, modern way to write it, with slices, Immer, thunks, memoised selectors and RTK Query.
Think of it like this
A bank. You can't walk into the vault and change your balance yourself. You fill in a slip ("deposit ₹500 to account 42"), hand it to the cashier, and the cashier follows fixed rules to write the new balance into the ledger. Every slip is kept, so anyone can replay the day and see exactly how each balance came to be. In Redux, the slip is an action, the cashier's rules are the reducer, and the ledger is the store.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Store
- The one object that holds Redux state. You read it with selectors and change it only by dispatching actions.
- Action
- A plain object describing what happened:
{ type: "todos/added", payload: "Buy milk" }. - Dispatch
- Sending an action to the store so the reducers can work out the next state.
- Reducer
- A pure function
(state, action) => newStatethat never changes the old state. - Slice
- One feature's part of the state plus its reducers and generated actions, made by
createSlice. - Immer
- A library that lets you write mutating code on a draft and produces a new immutable state from it.
- Thunk
- A function you dispatch that can do async work and dispatch other actions.
- Middleware
- Code between
dispatchand the reducers that sees every action, such as a logger. - Memoised selector
- A selector that remembers its last inputs and result, and returns the same result while inputs are unchanged.
- RTK Query
- Redux Toolkit's built-in data fetching and caching tool, which generates hooks per endpoint.
Step by step
01Create a slice
Install with npm install @reduxjs/toolkit react-redux. Each feature gets a slice file with its initial state and reducers. Export the generated actions and the reducer.
import { createSlice } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: { items: [] },
reducers: {
itemAdded(state, action) {
state.items.push(action.payload); // Immer makes this safe
},
itemRemoved(state, action) {
state.items = state.items.filter((i) => i.id !== action.payload);
},
},
});
export const { itemAdded, itemRemoved } = cartSlice.actions;
export default cartSlice.reducer;02Configure the store and provide it
configureStore combines slice reducers under keys. The keys become the top level of the state: state.cart.items.
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./features/cart/cartSlice";
export const store = configureStore({ reducer: { cart: cartReducer } });
// main.jsx
// <Provider store={store}><App /></Provider>03Read with useSelector, change with dispatch
Components select the smallest value they need and dispatch actions made by the action creators.
function CartButton({ product }) {
const count = useSelector((state) => state.cart.items.length);
const dispatch = useDispatch();
return <button onClick={() => dispatch(itemAdded(product))}>Add ({count})</button>;
}04Follow one action through Redux
Every change takes the same path, which is why Redux apps are easy to debug: the DevTools show each action and the state before and after it.
05Add async with a thunk
createAsyncThunk wraps an async function and dispatches pending, fulfilled and rejected actions. The slice reacts in extraReducers. For plain server data, prefer RTK Query below.
06Type the hooks (TypeScript)
Derive the types from the store once and export pre-typed hooks, so every component gets correct types without repeating them. withTypes exists since react-redux 9.1.
import { useDispatch, useSelector } from "react-redux";
import type { store } from "./store";
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();Try it yourself
- 1
Return and mutate together
In the first example, change
addedtostate.items.push(...); return state.items;. Predict what happens. Run it: Immer throws[Immer] An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.Pick one style per reducer. - 2
Remove the memoisation
In the
createSelectorexample, replaceuseSelector(selectAffordable)withuseSelector((s) => s.products.items.filter((p) => p.price <= s.products.maxPrice)). Predict the logs for Unrelated change. Run it: a development warning about the selector returning a different result, andAffordablerenders on the unrelated click. - 3
Unwrap a failed thunk
In the thunk example, change the last lines to
try { await store.dispatch(fetchUser(2)).unwrap(); } catch (e) { console.log("caught:", e.message); }.unwrap()turns the rejected action back into a thrown error, which is handy in a form submit handler.
Code & diagrams
`added(...)` only **builds** an action object; nothing changes until it is dispatched. After the dispatch, the old state still has 1 item: Immer produced a new state instead of changing the old one. Click a todo to toggle it.
Preview
Console
Nothing logged yet.Expected console output
{ type: 'todos/added', payload: 'Write a reducer' }
old state untouched: 1 | new: 2
render TodoList
render Remaining: 2
click first todo
render TodoList
render Remaining: 1The thunk itself never reaches the reducers; the middleware only logs the plain actions it dispatches. `await dispatch(thunk)` resolves with the final action (it doesn't throw on failure; call `.unwrap()` on the result if you want a thrown error).
Preview
Console
Nothing logged yet.Expected console output
render Profile: idle
action: user/fetch/pending
render Profile: loading
action: user/fetch/fulfilled
render Profile: succeeded
action: user/fetch/pending
render Profile: loading
action: user/fetch/rejected
render Profile: failed
thunk result type: user/fetch/rejectedOn the unrelated change, `items` and `maxPrice` are the same references (Immer kept them), so `createSelector` returns the cached array: no filtering, no render. Changing `maxPrice` recomputes once and renders once.
Preview
Console
Nothing logged yet.Expected console output
filtering, run 1
render Affordable: Pen, Mug
click Unrelated change
click Max ₹1000
filtering, run 2
render Affordable: Pen, Bag, Mug// src/services/postsApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
type Post = { id: number; title: string };
export const postsApi = createApi({
reducerPath: "postsApi",
baseQuery: fetchBaseQuery({ baseUrl: "/api" }),
tagTypes: ["Post"],
endpoints: (build) => ({
getPosts: build.query<Post[], void>({
query: () => "posts",
providesTags: ["Post"],
}),
addPost: build.mutation<Post, { title: string }>({
query: (body) => ({ url: "posts", method: "POST", body }),
invalidatesTags: ["Post"], // refetches getPosts after a successful add
}),
}),
});
export const { useGetPostsQuery, useAddPostMutation } = postsApi;
// store.ts: add the API's reducer and middleware
// configureStore({
// reducer: { [postsApi.reducerPath]: postsApi.reducer, cart: cartReducer },
// middleware: (getDefault) => getDefault().concat(postsApi.middleware),
// });
// In a component:
// const { data: posts, isLoading, error } = useGetPostsQuery();
// const [addPost, { isLoading: saving }] = useAddPostMutation();Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A selector that returns a new object
Write const view = useSelector((s) => ({ count: s.todos.items.length })); in a component.
Break #2
Mutating state outside a slice reducer
In a component, write const items = useSelector((s) => s.todos.items); items.push(newTodo);.
Myth vs fact
Myth
Redux is dead.
Fact
Redux Toolkit is actively maintained and widely used in large apps. What faded is hand-written Redux boilerplate and the habit of putting server data in Redux by hand; RTK and RTK Query replaced both.
Myth
Redux means huge amounts of boilerplate.
Fact
That was pre-2019 Redux. With RTK a feature is one createSlice call; action types and creators are generated.
Myth
Immer makes Redux state mutable.
Fact
You write mutating code on a draft, but every reducer still produces a new, frozen, immutable state. Old states stay untouched, which is what makes time-travel debugging work.
Myth
Everything in the app should go in the Redux store.
Fact
The Redux docs themselves recommend keeping form state and UI state local unless it is truly global. Server data belongs in RTK Query or another cache.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
RTK 2.0 (December 2023) removed the object syntax for
extraReducersandcreateReducer(use the builder callback), madeconfigureStore'smiddlewareoption a callback only, and moved to Reselect 5 and Immer 10. Version 2 also addedcombineSlicesand lazy-loaded slices (inject) for code-split apps. - ▸
useSelectorruns every subscribed selector after every dispatch. In an app with thousands of subscribed rows, selector cost adds up: keep selectors cheap, memoise derived data, and use normalised state withcreateEntityAdapter(ids array plusentitiesby id), which makes "select one item by id" O(1) and updates touch only one entry. - ▸
Side effects that react to actions ("when a todo is added, save a draft; when the user logs out, clear caches") fit
createListenerMiddleware:startListening({ actionCreator: added, effect: async (action, api) => { ... } }). It's RTK's lighter alternative to redux-saga and redux-observable. - ▸
The development checks (
immutableCheck,serializableCheck) warn when you store non-serialisable values likeDate,Map, class instances or promises. Store ISO strings and plain objects instead, so DevTools, persistence and time travel keep working.
Remember this
- 1
Redux is a pattern and a library for global client state, popular since 2015 and still widely used in large apps. Three rules define it. One store holds the app's global state as a plain object tree. The state is read-only: the only way to change it is to dispatch an action, a plain object describing what happened, like
{ type: "todos/added", payload: "Buy milk" }. Changes are made by reducers: pure functions(state, action) => newStatethat return a new state without mutating the old one, exactly likeuseReducer(Topic 9.4), but for the whole app. - 2
Redux Toolkit (RTK, package
@reduxjs/toolkit) is the official way to write Redux today; hand-written Redux withswitchstatements and action-type constants is legacy (the oldcreateStoreis marked deprecated). **configureStorecreates the store with good defaults: the thunk middleware, development checks that catch mutations and non-serialisable values, and Redux DevTools support.createSlice** takes aname, aninitialStateand areducersobject and generates the reducer and an action creator for each case: a slice namedtodoswith a reduceraddedgives youtodosSlice.actions.added(payload), which builds{ type: "todos/added", payload }. - 3
Slice reducers look like they mutate (
state.items.push(todo)), but they don't. RTK runs them through Immer: your code edits a draft, a stand-in proxy object that records every change, and Immer builds a brand-new immutable state from those changes, sharing every untouched part with the old state (structural sharing). Two rules: either mutate the draft or return a new value, never both; and don't mutate outside a slice reducer (in components the state is frozen in development, so mutation throws). - 4
react-redux connects the store to React. Wrap the app in
<Provider store={store}>. Read with **useSelector(selector)**: after every dispatched action, react-redux runs each component's selector and re-renders the component only if the result changed by===(reference). Change state with **dispatch(action)fromuseDispatch()**. Selectors that compute new arrays or objects (state.items.filter(...)) return a new reference every time, so the component re-renders on every action; in development react-redux warnsSelector unknown returned a different result when called with the same parameters. Fix it with **createSelector(from Reselect, re-exported by RTK), which memoises**: it recomputes only when its input selectors' results change, otherwise it returns the cached result. - 5
Async work goes in thunks: functions dispatched like actions that can wait and dispatch other actions. **
createAsyncThunk("user/fetch", async (id) => api.getUser(id))** dispatchesuser/fetch/pendingbefore the call, thenuser/fetch/fulfilledwith the result aspayload, oruser/fetch/rejectedwithaction.errorif it throws. The slice handles them in **extraReducers** with the builder:builder.addCase(fetchUser.fulfilled, (state, action) => { ... }). Middleware sits betweendispatchand the reducers and sees every action (logging, analytics, thunks themselves are middleware). - 6
RTK Query (part of RTK) is a server-state tool like TanStack Query (Topic 14.6), built into the Redux store. You describe endpoints with **
createApi** andfetchBaseQuery, and it generates hooks such asuseGetPostsQuery()anduseAddPostMutation(), with caching, de-duplication, loading flags and tag-based invalidation (providesTags/invalidatesTags). When Redux fits: large apps with lots of global client state changed from many places, teams that want strict conventions and a full action history (DevTools time travel), complex cross-feature logic (listener middleware, undo), or an existing Redux codebase. For a small app with a theme and a cart, Zustand or context is lighter; for server data, use RTK Query or TanStack Query rather than hand-written thunks.
Explain it without notes
Explain Redux's data flow from a click to a re-render.
What does Redux Toolkit add over plain Redux, and why can slice reducers "mutate"?
How does useSelector decide whether to re-render, and why do you need memoised selectors?
What does createAsyncThunk do, and when would you use RTK Query instead?
When is Redux a good fit, and when is it overkill?
Practice
Write a counterSlice with incremented, decremented and incrementedBy(amount), configure a store, and dispatch three actions from plain JavaScript, logging the state each time.
Write a memoised selector selectTotal that returns the cart total from state.cart.items (each item has price and qty).
Trade-offs
- ↔
Redux gives one predictable data flow, great DevTools and strong conventions, at the cost of more concepts (actions, reducers, middleware, selectors) and more files than Zustand for the same feature.
- ↔
One global store makes cross-feature logic and debugging easy, but every dispatch runs every subscribed selector; very large apps need memoisation and normalised state to stay fast.
Done when you can
I can create a slice and export its actions and reducer.
I can configure a store and provide it with
<Provider>.I read state with
useSelectorand change it withdispatch.I can write a thunk with
createAsyncThunkand handle it inextraReducers.I memoise derived data with
createSelector.I can explain what RTK Query does and when Redux fits.