| | |

Redux Toolkit 10 ⚛️ Thunk Lifecycle — Managing pending, fulfilled, and rejected states

⏱️ Estimated read: 14 min | |

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

FieldPresent InPurpose
argAll threeThe argument passed to the thunk
requestIdAll threeUnique identifier for the dispatch
requestStatusAll three'pending', 'fulfilled', or 'rejected'
abortedrejectedtrue when the request was cancelled
conditionrejectedtrue when the condition prevented the request

Lifecycle State Patterns

PatternPurpose
Boolean loadingOne request at a time
Counter pendingCountMultiple concurrent requests
currentRequestIdIgnore stale responses
meta.aborted checkSkip error display for cancellations

Handling Patterns

PatternUse Case
addCase for each phaseStandard lifecycle
addMatcher for all pending/rejectedShared lifecycle handling
requestId comparisonOut-of-order completion
aborted checkCancellation 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

PitfallWhy It HappensFix
Stale results overwrite newer onesNo requestId trackingCompare action.meta.requestId
Error shown for cancelled requestNo aborted checkif (action.meta.aborted) return
Loading spinner never clearsCounter not decremented on rejectedDecrement in both fulfilled and rejected
Error persists after successError not cleared on pendingstate.error = null in pending
Counter goes negativeUnmatched fulfilledMath.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

ItemValue
pending metaarg, requestId, requestStatus
fulfilled metaSame, plus the payload
rejected metaSame, plus aborted and condition
Stale-response fixCompare action.meta.requestId
Cancellation fixCheck action.meta.aborted
Concurrent requestsCounter or separate flags
Error clearstate.error = null in pending
Abort from componentpromise.abort() in the cleanup function
Generic handlingaddMatcher for all pending/rejected

Key takeaways:

  • The meta.requestId distinguishes request instances. When multiple requests of the same thunk are in flight, the requestId allows the slice to ignore the responses from superseded requests. The pending handler records the ID, and the fulfilled and rejected handlers compare against it .
  • The meta.aborted flag distinguishes cancellation from failure. A request that was cancelled by the user or by a newer dispatch should not display an error message. The rejected handler checks the flag and returns early for cancellations.
  • A single boolean is not enough for concurrent requests. The pendingCount counter increments on pending and decrements on fulfilled and rejected. The loading state is pendingCount > 0, which remains true until every request settles.
  • The meta.condition flag indicates that the thunk never ran. When the condition option returns false, the rejected action fires with condition: true and no pending action. The slice should skip the error handling for this case.
  • The currentRequestId is cleared when the request settles. Without the clear, the next request’s pending action would overwrite the ID, but the previous request’s fulfilled action 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 by dispatch(thunk()) has an abort method. Calling it from the useEffect cleanup 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.


Was this helpful?


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!