Redux Toolkit is the officially recommended way to write Redux — it wraps the original library's verbose action-type-constant / action-creator / switch-statement-reducer boilerplate into createSlice, and replaces manual immutable-update spreading with Immer, which lets you write reducer logic that looks mutating but produces a correct immutable update under the hood.
Store Configuration with Full Type Safety
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from '../features/cart/cartSlice'
import productsReducer from '../features/products/productsSlice'
export const store = configureStore({
reducer: {
cart: cartReducer,
products: productsReducer
}
})
// Inferred from the store itself — RootState and AppDispatch always stay
// correct as slices are added or removed, with zero manual type maintenance.
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatchimport { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'
import type { RootState, AppDispatch } from './store'
// Pre-typed replacements for the plain useDispatch/useSelector — use these
// EVERYWHERE in the app instead of the raw react-redux hooks. Using the
// plain hooks means annotating (state: RootState) by hand at every call
// site, which is easy to forget and drifts silently if RootState changes.
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelectorA Production-Grade cartSlice
createSlice generates action creators and a reducer from a single object. Inside each reducer function, state appears mutable (state.items.push(...)) but Immer intercepts these operations and produces a real immutable update behind the scenes — you get mutation-style ergonomics with Redux's immutability guarantee intact.
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
export interface CartItem {
productId: string
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
}
const initialState: CartState = {
items: []
}
const cartSlice = createSlice({
name: 'cart',
initialState,
reducers: {
// PayloadAction<T> types action.payload as T — here, everything needed
// to add a new line item.
itemAdded: (state, action: PayloadAction<Omit<CartItem, 'quantity'>>) => {
const existing = state.items.find((item) => item.productId === action.payload.productId)
if (existing) {
// Looks like a direct mutation — Immer converts this into a correct
// immutable update of the items array under the hood.
existing.quantity += 1
} else {
state.items.push({ ...action.payload, quantity: 1 })
}
},
quantityChanged: (
state,
action: PayloadAction<{ productId: string; quantity: number }>
) => {
const item = state.items.find((i) => i.productId === action.payload.productId)
if (item) {
item.quantity = Math.max(1, action.payload.quantity)
}
},
itemRemoved: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((item) => item.productId !== action.payload)
},
cartCleared: (state) => {
state.items = []
}
}
})
export const { itemAdded, quantityChanged, itemRemoved, cartCleared } = cartSlice.actions
// Selectors colocated with the slice — the single source of truth for how
// to derive data from this slice's shape.
export const selectCartItems = (state: { cart: CartState }) => state.cart.items
export const selectCartItemCount = (state: { cart: CartState }) =>
state.cart.items.reduce((total, item) => total + item.quantity, 0)
export const selectCartTotal = (state: { cart: CartState }) =>
state.cart.items.reduce((total, item) => total + item.price * item.quantity, 0)
export default cartSlice.reducerReducers must be pure and synchronous — no fetch calls inside createSlice's reducers block. createAsyncThunk wraps an async function and automatically dispatches three lifecycle actions around it: pending when it starts, fulfilled with the resolved value if it succeeds, and rejected with the error if it throws. A slice's extraReducers listens for these three action types to drive loading/error/data state.
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit'
export interface Product {
id: string
name: string
price: number
category: string
imageUrl: string
}
interface ProductsState {
items: Product[]
status: 'idle' | 'loading' | 'succeeded' | 'failed'
error: string | null
}
const initialState: ProductsState = {
items: [],
status: 'idle',
error: null
}
// createAsyncThunk<Returned, ThunkArg>('type', payloadCreator).
// Returned = Product[] (what the promise resolves to on success).
// ThunkArg = string (the category filter passed in when dispatched).
export const fetchProducts = createAsyncThunk<Product[], string>(
'products/fetchProducts',
async (category, { rejectWithValue }) => {
try {
const response = await fetch(`/api/products?category=${encodeURIComponent(category)}`)
if (!response.ok) {
// rejectWithValue lets the rejected case carry a specific, typed
// error payload instead of just the generic thrown-error message.
return rejectWithValue(`Failed to load products: ${response.status}`)
}
const data: Product[] = await response.json()
return data
} catch {
return rejectWithValue('Network error while fetching products')
}
}
)
const productsSlice = createSlice({
name: 'products',
initialState,
reducers: {},
// extraReducers handles actions NOT defined in this slice's own
// `reducers` block — specifically, the three lifecycle actions that
// createAsyncThunk generates automatically.
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.status = 'loading'
state.error = null
})
.addCase(fetchProducts.fulfilled, (state, action: PayloadAction<Product[]>) => {
state.status = 'succeeded'
state.items = action.payload
})
.addCase(fetchProducts.rejected, (state, action) => {
state.status = 'failed'
// action.payload is typed via rejectWithValue's return above;
// action.error.message is the fallback for uncaught throws.
state.error = (action.payload as string) ?? action.error.message ?? 'Unknown error'
})
}
})
export const selectProducts = (state: { products: ProductsState }) => state.products.items
export const selectProductsStatus = (state: { products: ProductsState }) => state.products.status
export const selectProductsError = (state: { products: ProductsState }) => state.products.error
export default productsSlice.reducerimport { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from '../app/hooks'
import { fetchProducts, selectProducts, selectProductsStatus, selectProductsError } from '../features/products/productsSlice'
function ProductGrid() {
const dispatch = useAppDispatch()
const products = useAppSelector(selectProducts)
const status = useAppSelector(selectProductsStatus)
const error = useAppSelector(selectProductsError)
useEffect(() => {
if (status === 'idle') {
// TypeScript enforces the string argument here because
// fetchProducts was declared as createAsyncThunk<Product[], string>.
dispatch(fetchProducts('all'))
}
}, [status, dispatch])
if (status === 'loading') {
return (
<div className="d-flex justify-content-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading…</span>
</div>
</div>
)
}
if (status === 'failed') {
return <div className="alert alert-danger">{error}</div>
}
return (
<div className="row g-3">
{products.map((product) => (
<div key={product.id} className="col-12 col-sm-6 col-lg-4">
{/* ProductCard from Module 1, wired to real product data */}
</div>
))}
</div>
)
}
export default ProductGridModule 4 Takeaways
- RootState and AppDispatch should be inferred from the store itself (ReturnType<typeof store.getState>, typeof store.dispatch) — never hand-written, so they can't drift out of sync as slices change.
- useAppDispatch/useAppSelector wrap the raw react-redux hooks with the app's real types — use them everywhere instead of the untyped originals.
- createSlice's reducers can use mutation-style syntax (state.items.push(...)) safely — Immer converts it to a real immutable update.
- createAsyncThunk<Returned, ThunkArg> generates pending/fulfilled/rejected actions automatically; a slice's extraReducers with the builder API handles them with full type inference on action.payload.
- rejectWithValue lets a rejected thunk carry a specific typed error payload instead of relying solely on the generic thrown-error message.