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

src/app/store.tstypescript
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.dispatch
src/app/hooks.tstypescript
import { 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> = useSelector

A 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.

src/features/cart/cartSlice.tstypescript
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.reducer

Reducers 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.

src/features/products/productsSlice.tstypescript
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.reducer
src/components/ProductGrid.tsx (dispatching the thunk)typescript
import { 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 ProductGrid

Module 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.