Redux Thunk Pattern
Handle async operations with createAsyncThunk for structured pending/fulfilled/rejected lifecycle management
When to Use
- Fetching data from an API and storing it in Redux state
- Performing async operations that need loading/error state tracking
- Dispatching multiple actions in sequence (fetch then transform)
- When RTK Query is overkill (simple one-off fetches, non-REST operations)
Instructions
- Define thunks with
createAsyncThunkusing a descriptive action type prefix:'<slice>/<operation>'. - The payload creator receives two arguments: the single argument passed to
dispatch(thunk(arg)), andthunkAPIwhich providesdispatch,getState,rejectWithValue, andsignal. - Always use
rejectWithValuefor known error shapes — it gives the reducer a typed payload instead of a serialized error. - Handle all three lifecycle actions (
pending,fulfilled,rejected) in the slice'sextraReducers. - Track loading state with an enum (
'idle' | 'loading' | 'succeeded' | 'failed') rather than separate booleans. - Use
conditionto prevent duplicate fetches — returnfalseto skip execution. - Use
thunkAPI.signalto support cancellation viaAbortController.
// features/users/users.thunks.ts
import { createAsyncThunk } from '@reduxjs/toolkit';
import { RootState } from '../../store';
interface User {
id: string;
name: string;
email: string;
}
export const fetchUsers = createAsyncThunk<
User[], // Return type
void, // Argument type
{ state: RootState; rejectValue: string }
>(
'users/fetchAll',
async (_, { rejectWithValue, signal }) => {
const response = await fetch('/api/users', { signal });
if (!response.ok) {
return rejectWithValue(`Failed: ${response.status}`);
}
return response.json();
},
{
condition: (_, { getState }) => {
const { status } = getState().users;
// Don't fetch if already loading or loaded
return status === 'idle' || status === 'failed';
},
}
);
// features/users/users.slice.ts — 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';
state.error = action.payload ?? action.error.message ?? 'Unknown error';
});
},
Details
Thunk lifecycle: Dispatching a thunk returns a promise. The thunk dispatches pending immediately, then fulfilled or rejected when the async work completes. The returned promise resolves with the action object in all cases (even rejection).
Unwrapping results: Use .unwrap() to get the payload directly or throw on rejection — useful in component handlers:
try {
const users = await dispatch(fetchUsers()).unwrap();
showSuccess(`Loaded ${users.length} users`);
} catch (err) {
showError(err as string);
}
Cancellation: When the component unmounts, abort the thunk:
useEffect(() => {
const promise = dispatch(fetchUsers());
return () => promise.abort();
}, [dispatch]);
When to use RTK Query instead: If you have a REST/GraphQL API with standard CRUD, caching, polling, or optimistic updates, RTK Query handles all of this automatically. Use createAsyncThunk for non-standard async work (WebSocket messages, file uploads, multi-step workflows).
Source
https://redux-toolkit.js.org/api/createAsyncThunk
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.