Redux Toolkit 10 ⚛️ Thunk Lifecycle — Managing pending, fulfilled, and rejected states
The previous chapter introduced createAsyncThunk and the three actions it generates. This chapter focuses on the lifecycle itself: the shape of each action, the meta fields that carry the request context, the way the slice tracks loading and error state across concurrent requests, and the patterns that prevent the common bugs—stale results overwriting newer ones, the loading spinner that never clears, and the error state that disappears when a retry succeeds.
The lifecycle is not just “start, success, failure.” Each action carries a meta.requestId and a meta.requestStatus that identify the specific request instance. When multiple requests are in flight, the requestId distinguishes them, and the slice can decide whether a response belongs to the current request or a superseded one. The meta.aborted flag distinguishes a cancellation from a failure. The meta.arg field carries the argument that was passed to the thunk, which is useful for filtering or displaying context.
Key point: The pending action is dispatched immediately when the thunk is called, before the payload creator runs. The fulfilled action is dispatched when the payload creator resolves, with the return value as the payload. The rejected action is dispatched when the payload creator throws, returns rejectWithValue, or the request is aborted, with the error or payload carrying the failure information. All three actions carry meta.requestId, meta.requestStatus, and meta.arg.
Why the lifecycle requires explicit state management
The concurrent-request problem.
A user types in a search box, and each keystroke dispatches a thunk. The requests complete out of order. Without tracking, the slice applies every response in the order it arrives, and the final state shows the result of the first keystroke, not the last. The meta.requestId allows the slice to track the most recent request and discard the responses from superseded ones. The condition option can prevent duplicate requests, but it does not solve the out-of-order completion problem .
The loading-state problem.
A single boolean loading field is sufficient for one request at a time. When two different thunks are dispatched concurrently—a fetch and a save—the second thunk’s pending action sets loading to true, and the first thunk’s fulfilled action sets it to false, even though the second request is still running. The slice needs either separate flags for each request type or a counter that increments on pending and decrements on fulfilled and rejected.
The error-state problem.
The rejected action fires for three distinct reasons: the payload creator threw an error, the payload creator returned rejectWithValue, or the request was aborted. The slice should not display an error message for an aborted request, because the user initiated the cancellation by navigating away or typing a new query. The meta.aborted flag allows the slice to skip the error handling for cancellations while still clearing the loading state.
a. The shape of the three actions
Each action has a type, an optional payload, an optional error, and a meta object. The meta object is the same shape for all three actions, with a few fields that differ.
// The three actions generated by createAsyncThunk
const pending = fetchUser.pending('requestId-1', 42)
// {
// type: 'user/fetch/pending',
// payload: undefined,
// meta: { arg: 42, requestId: 'requestId-1', requestStatus: 'pending' }
// }
const fulfilled = fetchUser.fulfilled(user, 'requestId-1', 42)
// {
// type: 'user/fetch/fulfilled',
// payload: user,
// meta: { arg: 42, requestId: 'requestId-1', requestStatus: 'fulfilled' }
// }
const rejected = fetchUser.rejected(error, 'requestId-1', 42)
// {
// type: 'user/fetch/rejected',
// payload: undefined,
// error: { message: '...' },
// meta: { arg: 42, requestId: 'requestId-1', requestStatus: 'rejected', aborted: false, condition: false }
// }
The meta.arg field is the argument that was passed to the thunk when it was dispatched. The meta.requestId is a unique identifier for this specific dispatch, generated by Redux Toolkit. The meta.requestStatus is the string 'pending', 'fulfilled', or 'rejected', which allows a generic matcher to handle all three phases. The rejected action’s meta also contains aborted and condition flags: aborted is true when the request was cancelled, and condition is true when the condition option returned false and the thunk never ran .
builder
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false
// Skip the error display for aborted requests
if (action.meta.aborted) return
// Skip the error display when the condition prevented the request
if (action.meta.condition) return
state.error = action.payload?.message ?? action.error.message ?? 'Unknown'
})
The meta.aborted check is essential for a search-as-you-type feature, where each keystroke cancels the previous request. Without it, the user sees an error for every keystroke that was superseded .
b. Tracking concurrent requests with a counter
A single boolean loading field cannot represent multiple concurrent requests. The slice should track the number of requests in flight, or use separate flags for each request type.
interface PostsState {
items: Post[]
pendingCount: number // Number of requests in flight
error: string | null
}
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], pendingCount: 0, error: null } as PostsState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.pendingCount += 1
state.error = null
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.pendingCount -= 1
state.items = action.payload
})
.addCase(fetchPosts.rejected, (state, action) => {
state.pendingCount -= 1
if (action.meta.aborted) return
state.error = action.payload ?? action.error.message ?? 'Unknown'
})
},
})
The pendingCount increments on pending and decrements on fulfilled and rejected. The component renders the loading state when pendingCount > 0. The counter handles the case where two requests are in flight: the first fulfilled action decrements the count to 1, and the second fulfilled action decrements it to 0. The loading state remains until both requests settle.
The addMatcher form can apply the counter to every thunk in the slice without repeating the increment and decrement in each addCase.
builder
.addMatcher(
(action) => action.type.endsWith('/pending'),
(state) => {
state.pendingCount += 1
state.error = null
}
)
.addMatcher(
(action) => action.type.endsWith('/fulfilled') || action.type.endsWith('/rejected'),
(state) => {
state.pendingCount = Math.max(0, state.pendingCount - 1)
}
)
The Math.max(0, ...) guard prevents the counter from going negative if a fulfilled or rejected action arrives without a matching pending action—which can happen if the thunk was dispatched before the slice was mounted, or if the actions are replayed in the DevTools .
c. Preventing stale results with requestId
When two requests of the same thunk are in flight, the later request should win. The slice can track the requestId of the most recent request and ignore the responses from older ones.
interface SearchState {
query: string
results: string[]
loading: boolean
currentRequestId: string | null
}
const searchSlice = createSlice({
name: 'search',
initialState: { query: '', results: [], loading: false, currentRequestId: null } as SearchState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(search.pending, (state, action) => {
state.loading = true
state.currentRequestId = action.meta.requestId
})
.addCase(search.fulfilled, (state, action) => {
// Ignore the response if a newer request has started
if (action.meta.requestId !== state.currentRequestId) return
state.loading = false
state.results = action.payload
state.currentRequestId = null
})
.addCase(search.rejected, (state, action) => {
if (action.meta.requestId !== state.currentRequestId) return
state.loading = false
if (action.meta.aborted) return
state.results = []
state.currentRequestId = null
})
},
})
The pending handler records the requestId of the most recent request. The fulfilled and rejected handlers check whether their requestId matches the recorded one. If it does not, the response is from a superseded request, and the slice ignores it. The currentRequestId is cleared when the current request settles, so the next request can record its own ID .
The search.pending action fires for every dispatch, including the one that is about to supersede the previous request. The previous request’s rejected action has a meta.aborted of true if it was cancelled, and the slice skips the error display for it. The requestId check handles the case where the previous request completes before it is cancelled, and the aborted check handles the case where it is cancelled after it completes .
Complete Example Session
// ============================================
// PART 1: THE THUNK
// ============================================
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
interface SearchResult { id: number; title: string }
export const search = createAsyncThunk<
SearchResult[],
string,
{ rejectValue: string }
>('search/query', async (query, { rejectWithValue, signal }) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal })
if (!response.ok) return rejectWithValue(`HTTP ${response.status}`)
return response.json()
})
// ============================================
// PART 2: THE STATE SHAPE
// ============================================
interface SearchState {
query: string
results: SearchResult[]
loading: boolean
error: string | null
currentRequestId: string | null
}
// ============================================
// PART 3: THE SLICE WITH REQUEST TRACKING
// ============================================
const searchSlice = createSlice({
name: 'search',
initialState: { query: '', results: [], loading: false, error: null, currentRequestId: null } as SearchState,
reducers: {},
extraReducers: (builder) => {
// ============================================
// PART 4: PENDING — RECORD THE REQUEST ID
// ============================================
builder.addCase(search.pending, (state, action) => {
state.loading = true
state.error = null
state.currentRequestId = action.meta.requestId
})
// ============================================
// PART 5: FULFILLED — IGNORE STALE RESPONSES
// ============================================
.addCase(search.fulfilled, (state, action) => {
if (action.meta.requestId !== state.currentRequestId) return
state.loading = false
state.results = action.payload
state.query = action.meta.arg
state.currentRequestId = null
})
// ============================================
// PART 6: REJECTED — HANDLE ABORT AND ERROR
// ============================================
.addCase(search.rejected, (state, action) => {
if (action.meta.requestId !== state.currentRequestId) return
state.loading = false
if (action.meta.aborted) return // Cancelled; not an error
state.error = action.payload ?? action.error.message ?? 'Search failed'
state.currentRequestId = null
})
},
})
export default searchSlice.reducer
// ============================================
// PART 7: THE COMPONENT
// ============================================
import { useState, useEffect } from 'react'
import { useAppDispatch, useAppSelector } from './hooks'
function SearchBox() {
const dispatch = useAppDispatch()
const [query, setQuery] = useState('')
const { results, loading, error } = useAppSelector((state) => state.search)
useEffect(() => {
if (!query.trim()) return
const promise = dispatch(search(query))
// Cancel the request when the component unmounts or the query changes
return () => promise.abort()
}, [query, dispatch])
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{loading && <p>Searching...</p>}
{error && <p>Error: {error}</p>}
<ul>
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</div>
)
}
The seven parts demonstrate the complete lifecycle management: the thunk, the state shape with currentRequestId, the three handlers with request tracking, and the component that aborts the request when the query changes. The critical distinction is between Parts 5 and 6: the fulfilled handler ignores stale responses, and the rejected handler skips the error display for aborted requests.
Quick Reference
Action Meta Fields
| Field | Present In | Purpose |
|---|---|---|
arg | All three | The argument passed to the thunk |
requestId | All three | Unique identifier for the dispatch |
requestStatus | All three | 'pending', 'fulfilled', or 'rejected' |
aborted | rejected | true when the request was cancelled |
condition | rejected | true when the condition prevented the request |
Lifecycle State Patterns
| Pattern | Purpose |
|---|---|
Boolean loading | One request at a time |
Counter pendingCount | Multiple concurrent requests |
currentRequestId | Ignore stale responses |
meta.aborted check | Skip error display for cancellations |
Handling Patterns
| Pattern | Use Case |
|---|---|
addCase for each phase | Standard lifecycle |
addMatcher for all pending/rejected | Shared lifecycle handling |
requestId comparison | Out-of-order completion |
aborted check | Cancellation vs failure |
Best Practices
✅ Do This:
// Record the requestId on pending
state.currentRequestId = action.meta.requestId # ✅
// Check the requestId on fulfilled and rejected
if (action.meta.requestId !== state.currentRequestId) return # ✅
// Skip the error display for aborted requests
if (action.meta.aborted) return # ✅
// Use a counter for concurrent requests
state.pendingCount += 1 # ✅
// Clear the error on a new request
state.error = null # ✅
// Abort the request when the component unmounts
return () => promise.abort() # ✅
❌ Don’t Do This:
// Apply every response in arrival order
state.results = action.payload // Stale response overwrites # ❌
// Show an error for a cancelled request
state.error = action.error.message // User sees "Aborted" # ❌
// Use a single boolean for concurrent requests
state.loading = false // One request clears another's flag # ❌
// Forget to decrement the counter on rejected
state.pendingCount += 1 // Counter never returns to zero # ❌
// Ignore the condition flag
// The condition prevented the request; no error should show # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Stale results overwrite newer ones | No requestId tracking | Compare action.meta.requestId |
| Error shown for cancelled request | No aborted check | if (action.meta.aborted) return |
| Loading spinner never clears | Counter not decremented on rejected | Decrement in both fulfilled and rejected |
| Error persists after success | Error not cleared on pending | state.error = null in pending |
| Counter goes negative | Unmatched fulfilled | Math.max(0, count - 1) |
Real-World Examples
1. Basic loading state
.addCase(fetchUser.pending, (state) => { state.loading = true })
.addCase(fetchUser.fulfilled, (state) => { state.loading = false })
.addCase(fetchUser.rejected, (state) => { state.loading = false })
2. Request ID tracking
.addCase(search.pending, (state, action) => { state.currentRequestId = action.meta.requestId })
.addCase(search.fulfilled, (state, action) => {
if (action.meta.requestId !== state.currentRequestId) return
state.results = action.payload
})
3. Abort check
.addCase(search.rejected, (state, action) => {
if (action.meta.aborted) return
state.error = action.error.message
})
4. Pending counter
.addCase(fetchPosts.pending, (state) => { state.pendingCount += 1 })
.addCase(fetchPosts.fulfilled, (state) => { state.pendingCount -= 1 })
5. Error clear on pending
.addCase(fetchPosts.pending, (state) => { state.error = null })
6. Condition check
.addCase(fetchUser.rejected, (state, action) => {
if (action.meta.condition) return
state.error = action.error.message
})
7. Matcher for all pending
.addMatcher((a) => a.type.endsWith('/pending'), (state) => { state.pendingCount += 1 })
8. Matcher for all settled
.addMatcher(
(a) => a.type.endsWith('/fulfilled') || a.type.endsWith('/rejected'),
(state) => { state.pendingCount = Math.max(0, state.pendingCount - 1) }
)
9. Clearing the request ID
state.currentRequestId = null
10. Aborting in a component
const promise = dispatch(search(query))
return () => promise.abort()
Visual
The Lifecycle with Request Tracking
┌──────────────────────────────────────────────────────────────┐
│ currentRequestId PREVENTS STALE RESPONSES │
│ │
│ Request 1: dispatch(search("a")) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ pending: currentRequestId = "req-1" │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Request 2: dispatch(search("ab")) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ pending: currentRequestId = "req-2" │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Request 1 completes: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ fulfilled: requestId "req-1" │ │
│ │ "req-1" !== "req-2" → IGNORED │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Request 2 completes: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ fulfilled: requestId "req-2" │ │
│ │ "req-2" === "req-2" → applied │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Aborted vs Rejected
┌──────────────────────────────────────────────────────────────┐
│ meta.aborted DISTINGUISHES CANCELLATION FROM FAILURE │
│ │
│ Cancelled request: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ rejected action │ │
│ │ meta.aborted: true │ │
│ │ → Do not show an error │ │
│ │ → Just clear the loading state │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Failed request: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ rejected action │ │
│ │ meta.aborted: false │ │
│ │ → Show the error message │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Counter for Concurrent Requests
┌──────────────────────────────────────────────────────────────┐
│ THE COUNTER TRACKS MULTIPLE IN-FLIGHT REQUESTS │
│ │
│ dispatch(fetchA()) → pendingCount = 1 │
│ dispatch(fetchB()) → pendingCount = 2 │
│ │
│ fetchA fulfills → pendingCount = 1 (still loading) │
│ fetchB fulfills → pendingCount = 0 (done) │
│ │
│ The loading state is `pendingCount > 0`. │
│ It remains true until both requests settle. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
pending meta | arg, requestId, requestStatus |
fulfilled meta | Same, plus the payload |
rejected meta | Same, plus aborted and condition |
| Stale-response fix | Compare action.meta.requestId |
| Cancellation fix | Check action.meta.aborted |
| Concurrent requests | Counter or separate flags |
| Error clear | state.error = null in pending |
| Abort from component | promise.abort() in the cleanup function |
| Generic handling | addMatcher for all pending/rejected |
Key takeaways:
- The
meta.requestIddistinguishes request instances. When multiple requests of the same thunk are in flight, therequestIdallows the slice to ignore the responses from superseded requests. Thependinghandler records the ID, and thefulfilledandrejectedhandlers compare against it . - The
meta.abortedflag distinguishes cancellation from failure. A request that was cancelled by the user or by a newer dispatch should not display an error message. Therejectedhandler checks the flag and returns early for cancellations. - A single boolean is not enough for concurrent requests. The
pendingCountcounter increments onpendingand decrements onfulfilledandrejected. The loading state ispendingCount > 0, which remains true until every request settles. - The
meta.conditionflag indicates that the thunk never ran. When theconditionoption returnsfalse, therejectedaction fires withcondition: trueand nopendingaction. The slice should skip the error handling for this case. - The
currentRequestIdis cleared when the request settles. Without the clear, the next request’spendingaction would overwrite the ID, but the previous request’sfulfilledaction would still match if it arrived before the overwrite. Clearing the ID on settle prevents this window. - The
promise.abort()call cancels the request from the component. The promise returned bydispatch(thunk())has anabortmethod. Calling it from theuseEffectcleanup function cancels the request when the component unmounts or the query changes .
Remember: The thunk lifecycle is three actions with rich metadata. The requestId identifies the request instance, the aborted flag identifies cancellations, and the condition flag identifies requests that never ran. Use the requestId to ignore stale responses, the aborted flag to skip error displays, and a counter to track concurrent requests. Clear the error on pending so a retry does not show the previous failure. The result is a lifecycle that handles the real-world cases: out-of-order responses, rapid user input, and concurrent requests.
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!