Async Logic with Thunks
Redux reducers must be pure synchronous functions. But real applications need to fetch data, call APIs, and run conditional logic before dispatching actions. Thunks are the standard Redux solution: a thunk is a function that returns another function, which receives dispatch and getState and can run any async code before dispatching actions. Redux Toolkit ships createAsyncThunk to generate thunks with minimal boilerplate.
createAsyncThunk Signature
import { createAsyncThunk } from '@reduxjs/toolkit'
const myThunk = createAsyncThunk(
// 1. Action type prefix — RTK appends /pending, /fulfilled, /rejected
'featureName/actionName',
// 2. Payload creator — receives (arg, thunkAPI) and must return a value or throw
async (arg, thunkAPI) => {
// arg → whatever you pass when dispatching: dispatch(myThunk(arg))
// thunkAPI.dispatch → dispatch further actions
// thunkAPI.getState → read current store state
// thunkAPI.rejectWithValue → return a structured rejection instead of throwing
// thunkAPI.signal → AbortSignal for request cancellation
const result = await someAsyncOperation(arg)
return result // becomes action.payload in the fulfilled case
}
)A Complete fetchUsers Thunk
Here is a realistic implementation: fetch a list of users, handle HTTP errors, and surface structured error messages to the UI via rejectWithValue:
// src/features/users/usersSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
// ── Types ──────────────────────────────────────────────────────────────
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
interface UsersState {
items: User[]
selectedId: number | null
status: 'idle' | 'loading' | 'succeeded' | 'failed'
error: string | null
}
// ── Thunk ──────────────────────────────────────────────────────────────
export const fetchUsers = createAsyncThunk<
User[], // fulfilled payload type
void, // arg type (no arg here)
{ rejectValue: string } // type for rejectWithValue
>(
'users/fetchAll',
async (_, thunkAPI) => {
try {
const res = await fetch('/api/users', {
signal: thunkAPI.signal, // supports cancellation via thunk.abort()
})
if (!res.ok) {
// rejectWithValue returns a structured rejection that lands in
// action.payload (not action.error) in the rejected case
return thunkAPI.rejectWithValue(
`Server error: ${res.status} ${res.statusText}`
)
}
return res.json()
} catch (err) {
if (err instanceof Error) {
return thunkAPI.rejectWithValue(err.message)
}
return thunkAPI.rejectWithValue('Unknown error')
}
}
)
// ── Slice ──────────────────────────────────────────────────────────────
const usersSlice = createSlice({
name: 'users',
initialState: {
items: [],
selectedId: null,
status: 'idle',
error: null,
} as UsersState,
// Sync reducers live here
reducers: {
selectUser(state, action: PayloadAction<number>) {
state.selectedId = action.payload
},
clearUsers(state) {
state.items = []
state.status = 'idle'
},
},
// Async thunk actions are handled in extraReducers
extraReducers: builder => {
builder
.addCase(fetchUsers.pending, state => {
state.status = 'loading'
state.error = null
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = 'succeeded'
state.items = action.payload
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = 'failed'
// action.payload is the rejectWithValue string (typed above)
// action.error.message is the fallback for unexpected throws
state.error = action.payload ?? action.error.message ?? 'Fetch failed'
})
},
})
export const { selectUser, clearUsers } = usersSlice.actions
export default usersSlice.reducerUsing the Thunk in a Component
import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from '../../store/hooks'
import { fetchUsers, selectUser } from './usersSlice'
export function UserList() {
const dispatch = useAppDispatch()
const { items, status, error } = useAppSelector(state => state.users)
useEffect(() => {
// Only fetch once — skip if we already have data
if (status === 'idle') {
dispatch(fetchUsers())
}
}, [status, dispatch])
// The thunk returns a promise — you can await it for post-dispatch logic
async function handleRefresh() {
try {
await dispatch(fetchUsers()).unwrap()
console.log('Refreshed successfully')
} catch (err) {
console.error('Refresh failed:', err)
}
}
if (status === 'loading') return <p>Loading…</p>
if (status === 'failed') return <p style={{ color: 'red' }}>{error}</p>
return (
<>
<button onClick={handleRefresh}>Refresh</button>
<ul>
{items.map(user => (
<li key={user.id} onClick={() => dispatch(selectUser(user.id))}>
{user.name} — {user.email}
</li>
))}
</ul>
</>
)
}thunkAPI.dispatch and thunkAPI.getState
Thunks can orchestrate multiple dispatches and read current state before deciding what to do. This is their superpower over simple async functions:
import { createAsyncThunk } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from '../../store'
// Typed thunk with access to dispatch and state
export const submitOrder = createAsyncThunk<
{ orderId: string },
{ items: CartItem[] },
{ state: RootState; dispatch: AppDispatch }
>(
'orders/submit',
async ({ items }, thunkAPI) => {
// Read state to get auth token
const token = thunkAPI.getState().auth.token
if (!token) {
return thunkAPI.rejectWithValue('Not authenticated')
}
const res = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ items }),
})
if (!res.ok) return thunkAPI.rejectWithValue('Order failed')
const order = await res.json()
// Dispatch a separate action to clear the cart after success
thunkAPI.dispatch(clearCart())
return order
}
)Cancellation with thunkAPI.signal
Every thunk receives an AbortSignal via thunkAPI.signal. Pass it to fetch to cancel in-flight requests when the user navigates away or when the component unmounts:
export const searchProducts = createAsyncThunk(
'products/search',
async (query: string, thunkAPI) => {
const res = await fetch(`/api/products?q=${query}`, {
signal: thunkAPI.signal, // cancelled if thunk.abort() is called
})
return res.json()
}
)
// In a component with a live search input:
useEffect(() => {
const thunkResult = dispatch(searchProducts(query))
// Cancel the in-flight request when the query changes
return () => {
thunkResult.abort()
}
}, [query, dispatch])Error Handling Patterns
// ── Pattern 1: structured error object ────────────────────────────────
export const createUser = createAsyncThunk<
User,
NewUser,
{ rejectValue: { field: string; message: string }[] }
>(
'users/create',
async (newUser, thunkAPI) => {
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
})
if (res.status === 422) {
const errors = await res.json() // [{ field: 'email', message: 'taken' }]
return thunkAPI.rejectWithValue(errors)
}
if (!res.ok) {
return thunkAPI.rejectWithValue([{ field: 'general', message: 'Server error' }])
}
return res.json()
}
)
// In the slice, errors is typed as { field, message }[]
.addCase(createUser.rejected, (state, action) => {
state.validationErrors = action.payload ?? []
})Thunks vs RTK Query
Use thunks for imperative async logic: multi-step workflows, conditional dispatches, operations that aren't simply "fetch and cache".
Use RTK Query for declarative data fetching: GET/POST/PUT/DELETE endpoints, automatic caching, background refetch, cache invalidation.
They complement each other — use RTK Query for standard CRUD, thunks for checkout flows, auth sequences, and complex multi-dispatch operations.
thunkAPI.dispatchandthunkAPI.getStateare only available in thunks — RTK Query endpoints don't have access to them directly.