Handle data that loads over time — why async needs special handling, createAsyncThunk for API calls, and tracking pending, fulfilled, and rejected states with extraReducers.
Why: createAsyncThunk builds that whole three-action sequence from a single async function. You give it a name and a function that does the fetch and returns the data; RTK automatically dispatches the pending action before it runs, fulfilled with your returned data on success, and rejected if it throws. Note: whatever you return becomes action.payload in the fulfilled case — that is the data your reducer will save.
// app/userSlice.ts
import { createAsyncThunk } from '@reduxjs/toolkit'
type User = { id: number; name: string }
// Argument 1: a name (used to build the pending/fulfilled/rejected actions)
// Argument 2: the async work — just return the data, or throw to fail
export const fetchUser = createAsyncThunk(
'user/fetch',
async (userId: number) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
if (!res.ok) throw new Error('Request failed')
// Whatever you return here becomes the "fulfilled" action's payload
return (await res.json()) as User
},
)
// Dispatch it like any action, passing its argument:
// dispatch(fetchUser(1))Why: the thunk dispatches the pending/fulfilled/rejected actions, and your slice listens for them in extraReducers — a second reducers block for actions defined OUTSIDE the slice. addCase wires each moment to a state change, so the component can show a spinner, the data, or an error. Note: reducers handle this slice's OWN actions; extraReducers handle actions from elsewhere, like a thunk.
// app/userSlice.ts (continued)
import { createSlice } from '@reduxjs/toolkit'
import { fetchUser } from './fetchUser'
type User = { id: number; name: string }
type UserState = {
data: User | null
status: 'idle' | 'loading' | 'failed'
}
const initialState: UserState = { data: null, status: 'idle' }
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {}, // no normal actions this time — all three come from the thunk
extraReducers: (builder) => {
builder
// The request started
.addCase(fetchUser.pending, (state) => {
state.status = 'loading'
})
// It succeeded — action.payload is the User you returned
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'idle'
state.data = action.payload
})
// It threw
.addCase(fetchUser.rejected, (state) => {
state.status = 'failed'
})
},
})
export default userSlice.reducerWhy: from the component side, a thunk is dispatched like any other action — dispatch(fetchUser(1)). Then you read the status and data with useAppSelector and render the right thing for each case. Note: dispatching from inside useEffect runs the fetch once when the component appears; for data fetching specifically, RTK Query in the next lesson does all of this with far less code.
'use client'
import { useEffect } from 'react'
import { useAppSelector, useAppDispatch } from '../hooks'
import { fetchUser } from '../fetchUser'
export default function UserCard() {
const dispatch = useAppDispatch()
const { data, status } = useAppSelector((state) => state.user)
// Kick off the fetch once when the component first appears
useEffect(() => {
dispatch(fetchUser(1))
}, [dispatch])
if (status === 'loading') return <p>Loading…</p>
if (status === 'failed') return <p>Could not load the user.</p>
if (!data) return null
return <p>Loaded: {data.name}</p>
}