Chapter 41 โ Redux Toolkit with TypeScript
๐ Definitions
- Redux Toolkit (RTK) โ the official, recommended way to write Redux. It removes the boilerplate from the previous chapter (action types, switch statements, spread operators, store setup).
- Slice โ one feature's state + reducers + auto-generated action creators, made with
createSlice. - Immer โ library RTK uses inside reducers so you can write "mutating" code that produces immutable updates.
configureStoreโ creates the store with good defaults: thunk middleware, DevTools, and dev-only checks for mutations and non-serialisable values.
๐ Plain Redux vs Redux Toolkit
| Task | Plain Redux | Redux Toolkit |
|---|---|---|
| Action types | const ADD = "cart/added" by hand |
Generated from slice name + reducer key |
| Action creators | Written by hand | slice.actions.added generated |
| Immutable update | { ...state, items: [...state.items, x] } |
state.items.push(x) (Immer) |
| Store setup | createStore + applyMiddleware + devtools enhancer |
configureStore({ reducer }) |
| Async | Install redux-thunk | Built in + createAsyncThunk |
| Data fetching | DIY | RTK Query |
๐ป Code Example โ A Typed Slice with PayloadAction
// features/cart/cartSlice.ts
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
export interface CartItem {
id: string;
name: string;
price: number;
qty: number;
}
interface CartState {
items: CartItem[];
couponCode: string | null;
}
const initialState: CartState = { items: [], couponCode: null }; // annotate โ state type inferred
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
itemAdded(state, action: PayloadAction<CartItem>) {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) existing.qty += action.payload.qty; // โ
Immer: "mutate" the draft
else state.items.push(action.payload);
},
itemRemoved(state, action: PayloadAction<string>) {
state.items = state.items.filter((i) => i.id !== action.payload);
},
qtyChanged(state, action: PayloadAction<{ id: string; qty: number }>) {
const item = state.items.find((i) => i.id === action.payload.id);
if (item) item.qty = Math.max(1, action.payload.qty);
},
couponApplied(state, action: PayloadAction<string>) {
state.couponCode = action.payload.trim().toUpperCase();
},
cartCleared() {
return initialState; // returning a new value replaces the state
},
},
// RTK 2: selectors can live on the slice; they receive this slice's state
selectors: {
selectCartItems: (cart) => cart.items,
selectCartCount: (cart) => cart.items.reduce((n, i) => n + i.qty, 0),
},
});
export const { itemAdded, itemRemoved, qtyChanged, couponApplied, cartCleared } = cartSlice.actions;
export const { selectCartItems, selectCartCount } = cartSlice.selectors;
export default cartSlice.reducer;
// itemAdded({ id: "1", name: "Pen", price: 20, qty: 1 })
// โ { type: "cart/itemAdded", payload: { ... } } fully typedImmer gotcha: inside a reducer either mutate the draft or return a new value โ never both.
state.items.push(x); return state;is fine, butstate.x = 1; return { ...state }throws.
๐ป Code Example โ Preparing Payloads (prepare callback)
import { createSlice, nanoid, type PayloadAction } from "@reduxjs/toolkit";
interface Todo { id: string; text: string; done: boolean; createdAt: string }
const todosSlice = createSlice({
name: "todos",
initialState: [] as Todo[],
reducers: {
todoAdded: {
reducer(state, action: PayloadAction<Todo>) {
state.push(action.payload);
},
// prepare runs in the action creator โ the right place for ids/dates,
// because reducers must stay pure
prepare(text: string) {
return { payload: { id: nanoid(), text, done: false, createdAt: new Date().toISOString() } };
},
},
todoToggled(state, action: PayloadAction<string>) {
const todo = state.find((t) => t.id === action.payload);
if (todo) todo.done = !todo.done;
},
},
});
todosSlice.actions.todoAdded("Learn RTK"); // caller passes only the text๐ป Code Example โ Store Setup and Inferred Types
// app/store.ts
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "../features/cart/cartSlice";
import authReducer from "../features/auth/authSlice";
import { api } from "../services/api";
export const store = configureStore({
reducer: {
cart: cartReducer,
auth: authReducer,
[api.reducerPath]: api.reducer, // RTK Query cache (Chapter 25)
},
middleware: (getDefault) => getDefault().concat(api.middleware),
devTools: import.meta.env.DEV,
});
// Infer types from the store itself โ never write RootState by hand
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;๐ป Code Example โ Typed Hooks (Do This Once)
// app/hooks.ts
import { useDispatch, useSelector, useStore } from "react-redux";
import type { AppDispatch, RootState } from "./store";
// react-redux 9.1+
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<typeof import("./store").store>();
// Older react-redux equivalent:
// export const useAppDispatch: () => AppDispatch = useDispatch;
// export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;Why? Plain useDispatch() doesn't know about thunks, so dispatch(fetchUser(1)) would be a type error; plain useSelector gives state: unknown.
๐ป Code Example โ Provider & Components
// main.tsx
import { StrictMode } from "react";
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(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
);// features/cart/Cart.tsx
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { itemRemoved, qtyChanged, selectCartItems, selectCartCount } from "./cartSlice";
import { selectCartTotal } from "./cartSelectors";
export function Cart() {
const items = useAppSelector(selectCartItems);
const count = useAppSelector(selectCartCount);
const total = useAppSelector(selectCartTotal);
const dispatch = useAppDispatch();
if (!items.length) return <p>Your cart is empty</p>;
return (
<section>
<h3>Cart ({count}) โ โน{total}</h3>
<ul>
{items.map((i) => (
<li key={i.id}>
{i.name}
<input
type="number"
min={1}
value={i.qty}
onChange={(e) => dispatch(qtyChanged({ id: i.id, qty: e.target.valueAsNumber }))}
/>
<button onClick={() => dispatch(itemRemoved(i.id))} aria-label={`Remove ${i.name}`}>ร</button>
</li>
))}
</ul>
</section>
);
}๐ป Code Example โ Reacting to Other Slices' Actions (extraReducers)
// When the user logs out, every feature should reset itself
import { createSlice } from "@reduxjs/toolkit";
import { loggedOut } from "../auth/authSlice";
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: { /* โฆ */ },
extraReducers: (builder) => {
builder.addCase(loggedOut, () => initialState);
},
});reducers = actions this slice owns; extraReducers = actions defined elsewhere (other slices, thunks, RTK Query) that this slice responds to. In RTK 2 the builder callback is the only supported form.
๐ป Code Example โ createAction and createReducer (Lower-level APIs)
import { createAction, createReducer } from "@reduxjs/toolkit";
export const themeToggled = createAction("ui/themeToggled");
export const toastShown = createAction<{ message: string; kind: "info" | "error" }>("ui/toastShown");
interface UiState { theme: "light" | "dark"; toasts: { message: string; kind: string }[] }
export const uiReducer = createReducer<UiState>({ theme: "light", toasts: [] }, (builder) => {
builder
.addCase(themeToggled, (s) => { s.theme = s.theme === "light" ? "dark" : "light"; })
.addCase(toastShown, (s, a) => { s.toasts.push(a.payload); })
.addMatcher((a) => a.type.endsWith("/rejected"), (s) => {
s.toasts.push({ message: "Something went wrong", kind: "error" });
});
});addMatcher handles any action that matches a predicate โ useful for global loading/error handling across all thunks.
๐ What Belongs in Redux?
| Put in Redux | Keep out of Redux |
|---|---|
| Auth user / tokens metadata | Form input while typing (local useState) |
| Cart, filters shared across pages | Modal open/closed for one component |
| Server data โ but via RTK Query | Derived values (compute with selectors) |
| UI state many components need (theme, toasts) | Non-serialisable things: Promises, class instances, DOM nodes |
๐ Real-World Impact
- RTK cuts a typical feature from ~4 files (types, actions, constants, reducer) to one slice.
- Inferred
RootStatemeans adding a slice updates everyuseAppSelectortype automatically. - Dev-mode immutability and serialisability checks catch bugs that used to take hours to find.
๐ฏ Likely Interview Questions
- What problems does Redux Toolkit solve? โ Boilerplate, accidental mutation, store setup, async logic, data fetching.
- What is a slice? โ
createSlicebundles name, initial state, reducers and generates action creators + action types. - How can RTK reducers "mutate" state? โ Immer gives you a draft proxy and produces a new immutable state from your changes.
- How do you type
RootStateandAppDispatch? โ Infer from the store:ReturnType<typeof store.getState>andtypeof store.dispatch. - Why create
useAppDispatch/useAppSelector? โ So dispatch knows about thunks and selectors get typed state without repeating generics. reducersvsextraReducers? โ Own actions vs actions defined elsewhere (thunks, other slices, RTK Query).- Where do you generate ids or timestamps? โ In a
preparecallback or the caller, never inside the reducer. - What should not go in the Redux store? โ Non-serialisable values and purely local UI state.
โ Redux Core Concepts | Index | Next: Async Logic with Thunks โ