Redux Toolkit 11 ⚛️ Thunk Dispatch — Dispatching additional actions inside thunks
A thunk is not limited to one action. The payload creator of a createAsyncThunk receives the thunkAPI object, which includes dispatch, and the thunk can dispatch any number of other actions before, during, or after the async operation. The previous chapters covered the three lifecycle actions that createAsyncThunk generates automatically; this chapter covers the actions that the thunk dispatches manually, the reasons for doing so, and the patterns that keep the thunk’s logic readable. The dispatch in thunkAPI is the same dispatch that the store provides, and it accepts the same action creators and thunks that a component would dispatch.
The manual dispatch is useful when the thunk needs to coordinate multiple slices, trigger a dependent thunk after the first one resolves, or dispatch a local action that the slice owns. The thunk is the orchestration layer: it runs the async operation, and it dispatches the actions that the rest of the application needs to react to the result. The lifecycle actions of the thunk itself are dispatched by createAsyncThunk, and the manual dispatches are additional actions that the thunk’s logic requires.
Key point: The thunkAPI.dispatch function is the store’s dispatch, and it accepts any action or thunk. The thunk can dispatch the action creators from its own slice, from other slices, and from the shared createAction module. The dispatches happen in the order the thunk writes them, and they are not batched. A thunk that dispatches a dependent thunk must await the result if the subsequent logic depends on it, and it must handle the rejection if the dependent thunk fails.
Why thunks dispatch additional actions
The cross-slice coordination problem.
A thunk that fetches a user’s profile may also need to update the auth slice’s lastSeen field, the notification slice’s unread count, and the UI slice’s loading flag. The thunk’s own lifecycle actions update the slice that defined the thunk, but they do not reach the other slices. The thunk dispatches the other slices’ actions manually, which is the mechanism that coordinates the update across the store . The alternative is to have the other slices listen to the thunk’s fulfilled action in their extraReducers, which is the pattern from the extraReducers chapter. Both approaches are valid, and the choice depends on whether the coordination belongs in the thunk or in the slices.
The dependent-thunk problem.
A thunk that creates an order may need to fetch the updated order list after the create succeeds. The thunk dispatches the create request, awaits the result, and then dispatches the fetch thunk. The dependent thunk is dispatched with dispatch(fetchOrders()), and the create thunk can await the returned promise if it needs to know when the list is refreshed . The alternative is to have the component dispatch both thunks in sequence, but the thunk is the better place for the orchestration because the component should not need to know the sequence.
The local-action problem.
A thunk may need to dispatch an action that its own slice owns, such as clearing a form or setting a local flag. The action creator is imported from the slice, and the thunk dispatches it with dispatch(clearForm()). The slice’s reducers object defines the action, and the thunk uses it as part of its logic . The local action is not part of the thunk’s lifecycle, and it is not handled by the thunk’s extraReducers; it is a separate action that the thunk chooses to dispatch.
a. The dispatch of local actions
The simplest manual dispatch is the action from the thunk’s own slice. The action creator is imported from the slice, and the thunk calls dispatch with it.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
interface FormState {
title: string
submitting: boolean
error: string | null
}
const formSlice = createSlice({
name: 'form',
initialState: { title: '', submitting: false, error: null } as FormState,
reducers: {
resetForm: (state) => {
state.title = ''
state.error = null
},
setError: (state, action) => {
state.error = action.payload
},
},
})
export const { resetForm, setError } = formSlice.actions
export const submitForm = createAsyncThunk<void, string>(
'form/submit',
async (title, { dispatch, rejectWithValue }) => {
// Dispatch a local action before the request
dispatch(formSlice.actions.setError(null))
const response = await fetch('/api/forms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!response.ok) {
// Dispatch a local action on failure
dispatch(setError(`HTTP ${response.status}`))
return rejectWithValue(`HTTP ${response.status}`)
}
// Dispatch a local action on success
dispatch(resetForm())
}
)
The submitForm thunk dispatches setError(null) before the request, setError(...) on failure, and resetForm() on success. The actions are defined in the same slice’s reducers object, and the thunk imports them . The thunk’s lifecycle actions—pending, fulfilled, and rejected—are still generated automatically, and the manual dispatches are additional actions that the thunk’s logic requires.
b. The dispatch of other slices’ actions
A thunk can dispatch the actions of other slices when the async operation affects multiple parts of the store. The action creators are imported from the other slices, and the thunk calls dispatch with them.
import { createAsyncThunk } from '@reduxjs/toolkit'
import { logout } from '../auth/authSlice'
import { clearCart } from '../cart/cartSlice'
import { addNotification } from '../notifications/notificationsSlice'
export const deleteAccount = createAsyncThunk<void, number>(
'user/deleteAccount',
async (userId, { dispatch, rejectWithValue }) => {
const response = await fetch(`/api/users/${userId}`, { method: 'DELETE' })
if (!response.ok) {
dispatch(addNotification({ message: 'Delete failed', level: 'error' }))
return rejectWithValue(`HTTP ${response.status}`)
}
// The account is deleted; clear the related state
dispatch(logout())
dispatch(clearCart())
dispatch(addNotification({ message: 'Account deleted', level: 'info' }))
}
)
The deleteAccount thunk dispatches the logout action from the auth slice, the clearCart action from the cart slice, and the addNotification action from the notifications slice . The three actions are imported from their respective slices, and the thunk is the place where the coordination lives. The alternative is for the auth, cart, and notification slices to listen to the thunk’s fulfilled action in their extraReducers, which would keep the coordination in the slices but require each slice to know about the thunk.
c. The dispatch of dependent thunks
A thunk can dispatch another thunk and await its result. The dispatch returns a promise that resolves with the fulfilled action or rejects with the rejected action.
import { createAsyncThunk } from '@reduxjs/toolkit'
import { fetchOrders } from './ordersSlice'
export const createOrder = createAsyncThunk<Order, NewOrder>(
'orders/create',
async (newOrder, { dispatch, rejectWithValue }) => {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newOrder),
})
if (!response.ok) return rejectWithValue(`HTTP ${response.status}`)
const created = await response.json()
// Dispatch the dependent thunk and await its result
await dispatch(fetchOrders())
return created
}
)
The createOrder thunk dispatches the fetchOrders thunk after the create succeeds, and it awaits the returned promise . The await dispatch(fetchOrders()) form ensures that the create thunk does not resolve until the fetch thunk has settled. If the fetch thunk fails, the rejection is not caught by the createOrder thunk, and the createOrder thunk still resolves with the created order. To propagate the failure, the thunk must catch the rejection and return it.
try {
await dispatch(fetchOrders()).unwrap()
} catch (error) {
return rejectWithValue(`Order created but refresh failed: ${error}`)
}
The .unwrap() call on the returned promise throws the rejected payload, which the try/catch catches . The rejectWithValue then propagates the failure to the createOrder thunk’s rejected action, and the slice’s rejected handler can display the error. Without the .unwrap(), the failure is silently ignored, and the createOrder thunk appears to succeed even though the refresh failed.
Complete Example Session
// ============================================
// PART 1: THE NOTIFICATIONS SLICE
// ============================================
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
interface Notification { id: number; message: string; level: 'info' | 'error' }
interface NotificationsState { items: Notification[] }
const notificationsSlice = createSlice({
name: 'notifications',
initialState: { items: [] } as NotificationsState,
reducers: {
addNotification: (state, action: PayloadAction<{ message: string; level: 'info' | 'error' }>) => {
state.items.push({ id: Date.now(), ...action.payload })
},
clearNotifications: (state) => {
state.items = []
},
},
})
export const { addNotification, clearNotifications } = notificationsSlice.actions
export default notificationsSlice.reducer
// ============================================
// PART 2: THE ORDERS SLICE
// ============================================
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
interface Order { id: number; title: string }
interface OrdersState { items: Order[]; loading: boolean; error: string | null }
export const fetchOrders = createAsyncThunk<Order[], void>(
'orders/fetch',
async () => {
const res = await fetch('/api/orders')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
)
export const createOrder = createAsyncThunk<Order, string, { rejectValue: string }>(
'orders/create',
async (title, { dispatch, rejectWithValue }) => {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!res.ok) return rejectWithValue(`HTTP ${res.status}`)
const created = await res.json()
// ============================================
// PART 3: THE MANUAL DISPATCHES
// ============================================
// Dispatch a cross-slice action
dispatch(addNotification({ message: 'Order created', level: 'info' }))
// Dispatch the dependent thunk and await it
try {
await dispatch(fetchOrders()).unwrap()
} catch {
dispatch(addNotification({ message: 'Refresh failed', level: 'error' }))
}
return created
}
)
const ordersSlice = createSlice({
name: 'orders',
initialState: { items: [], loading: false, error: null } as OrdersState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchOrders.pending, (state) => { state.loading = true })
.addCase(fetchOrders.fulfilled, (state, action) => { state.items = action.payload; state.loading = false })
.addCase(fetchOrders.rejected, (state, action) => { state.loading = false; state.error = action.error.message ?? null })
.addCase(createOrder.fulfilled, (state, action) => { state.items.push(action.payload) })
},
})
export default ordersSlice.reducer
// ============================================
// PART 4: THE COMPONENT
// ============================================
function CreateOrderForm() {
const dispatch = useAppDispatch()
const [title, setTitle] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await dispatch(createOrder(title))
if (createOrder.fulfilled.match(result)) {
setTitle('')
}
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button type="submit">Create</button>
</form>
)
}
The four parts demonstrate the thunk dispatch pattern: the notifications slice, the orders slice with the thunks, the manual dispatches inside the create thunk, and the component that triggers the thunk. The critical distinction is between Parts 2 and 3: the fetchOrders thunk is the dependent thunk, and the createOrder thunk dispatches it after the create succeeds and awaits its result.
Quick Reference
thunkAPI.dispatch
| Property | Purpose |
|---|---|
dispatch(action) | Dispatch a plain action |
dispatch(thunk()) | Dispatch another thunk |
dispatch(thunk()).unwrap() | Resolve payload or throw error |
Dispatch Patterns
| Pattern | Example |
|---|---|
| Local action | dispatch(resetForm()) |
| Cross-slice action | dispatch(logout()) |
| Dependent thunk | await dispatch(fetchOrders()) |
| Dependent thunk with error | await dispatch(fetchOrders()).unwrap() |
| Notification | dispatch(addNotification(...)) |
Matching Results
| Method | Purpose |
|---|---|
.unwrap() | Resolve payload or throw error |
.match(action) | Type guard on the action |
.fulfilled.match(result) | Check the result in the component |
.rejected.match(result) | Check the rejection in the component |
Best Practices
✅ Do This:
// Dispatch the dependent thunk and await it
await dispatch(fetchOrders()) # ✅
// Use unwrap to propagate the failure
await dispatch(fetchOrders()).unwrap() # ✅
// Catch the rejection and dispatch a notification
try { await dispatch(fetchOrders()).unwrap() } catch { ... } # ✅
// Dispatch cross-slice actions from the thunk
dispatch(logout()) # ✅
// Dispatch local actions from the thunk
dispatch(resetForm()) # ✅
❌ Don’t Do This:
// Fire and forget the dependent thunk
dispatch(fetchOrders()) // No await; the create resolves early # ❌
// Ignore the dependent thunk's failure
await dispatch(fetchOrders()) // The failure is swallowed # ❌
// Dispatch during the payload creator without the thunkAPI
dispatch(...) // No dispatch in scope without the argument # ❌
// Dispatch a component action from the thunk
dispatch(setLocalState()) // The component owns the state # ❌
// Use the thunk for component-specific side effects
dispatch(navigate('/')) // The component should navigate # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Dependent thunk not awaited | Missing await | Add await |
| Failure swallowed | No .unwrap() | Add .unwrap() and try/catch |
| Cross-slice action not dispatched | Missing import | Import the action creator |
| Local action not found | Wrong slice | Import from the correct slice |
| Thunk dispatched during render | Called in the component body | Dispatch in useEffect or an event handler |
Real-World Examples
1. Local action before request
dispatch(setError(null))
2. Local action on success
dispatch(resetForm())
3. Cross-slice logout
dispatch(logout())
4. Cross-slice notification
dispatch(addNotification({ message: 'Done', level: 'info' }))
5. Dependent thunk
await dispatch(fetchOrders())
6. Dependent thunk with unwrap
await dispatch(fetchOrders()).unwrap()
7. Dependent thunk with catch
try { await dispatch(fetchOrders()).unwrap() } catch (e) { ... }
8. Multiple cross-slice actions
dispatch(logout()); dispatch(clearCart())
9. Check the result in the component
if (createOrder.fulfilled.match(result)) { ... }
10. Dispatch after a delay
setTimeout(() => dispatch(clearNotifications()), 5000)
Visual
The Thunk Orchestration
┌──────────────────────────────────────────────────────────────┐
│ THE THUNK DISPATCHES THE LIFECYCLE ACTIONS AND THE MANUAL │
│ │
│ dispatch(createOrder('New')) │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ pending (automatic) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ manual: dispatch(addNotification(...)) │ │
│ │ manual: await dispatch(fetchOrders()).unwrap() │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ fulfilled (automatic) with the created order │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Cross-Slice Coordination
┌──────────────────────────────────────────────────────────────┐
│ ONE THUNK, THREE SLICES │
│ │
│ deleteAccount thunk │
│ │ │
│ ├── dispatch(logout()) → auth slice │
│ ├── dispatch(clearCart()) → cart slice │
│ └── dispatch(addNotification()) → notifications slice │
│ │
│ The thunk is the orchestration layer. │
│ The slices are independent. │
└──────────────────────────────────────────────────────────────┘
Await and Unwrap
┌──────────────────────────────────────────────────────────────┐
│ await dispatch(fetchOrders()).unwrap() THROWS ON FAILURE │
│ │
│ Without unwrap: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ await dispatch(fetchOrders()) │ │
│ │ → resolves with the action object │ │
│ │ → does not throw on failure │ │
│ │ → the create thunk continues as if it succeeded │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ With unwrap: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ try { │ │
│ │ await dispatch(fetchOrders()).unwrap() │ │
│ │ } catch (error) { │ │
│ │ // the rejection payload or the Error │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| thunkAPI property | dispatch |
| Local action | Action from the thunk’s own slice |
| Cross-slice action | Action from another slice |
| Dependent thunk | Another thunk dispatched with dispatch |
| Await | await dispatch(thunk()) |
| Propagate failure | .unwrap() and try/catch |
| Result matching | .fulfilled.match(result) |
| Component trigger | dispatch(thunk()) in an event handler |
Key takeaways:
- The thunk’s
dispatchis the store’s dispatch. It accepts any action or thunk, including the action creators from the thunk’s own slice, from other slices, and from the sharedcreateActionmodule . The manual dispatches are additional to the three lifecycle actions thatcreateAsyncThunkgenerates automatically. - The thunk is the orchestration layer. It runs the async operation, dispatches the actions that the other slices need, and dispatches the dependent thunks that the flow requires. The component does not need to know the sequence; it dispatches the thunk and renders the state.
- The dependent thunk must be awaited. The
await dispatch(fetchOrders())form ensures that the create thunk does not resolve until the fetch thunk has settled. Without theawait, the create thunk resolves before the refresh completes, and the component may render stale data . - The
.unwrap()propagates the failure. Theawait dispatch(fetchOrders()).unwrap()form throws the rejected payload, which thetry/catchcatches. Without theunwrap, the failure is silently ignored, and the create thunk appears to succeed even though the refresh failed . - The cross-slice actions are imported from their slices. The
dispatch(logout())anddispatch(clearCart())calls use the action creators from the auth and cart slices, and the thunk is the place where the coordination lives. The alternative is for the slices to listen to the thunk’sfulfilledaction in theirextraReducers. - The component should not own the orchestration. The thunk owns the sequence, and the component dispatches the thunk and renders the state. A component that dispatches multiple thunks in sequence is a component that knows too much about the flow .
Remember: A thunk is not limited to its own lifecycle actions. It can dispatch the actions from its own slice, from other slices, and the dependent thunks that the flow requires. The dispatch in thunkAPI is the store’s dispatch, and it is the mechanism that coordinates the update across the store. The dependent thunk must be awaited, and the failure must be propagated with .unwrap(). The component dispatches the thunk and renders the state, and the thunk owns the sequence. The result is a flow that is orchestrated in one place, with the slices and the components staying independent.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!