What is createAsyncThunk?
createAsyncThunk is a Redux Toolkit utility that simplifies asynchronous operations. It automatically manages the complete async lifecycle by generating action types for pending, fulfilled, and rejected states, eliminating the boilerplate traditionally required for async Redux patterns.
Before Redux Toolkit, implementing data fetching required creating action type constants, writing action creators, dispatching start/success/failure actions manually, and handling errors with try-catch blocks. createAsyncThunk abstracts this entire pattern into a single function call, allowing developers to focus on the actual async logic rather than the surrounding infrastructure code.
The function accepts an action type string and a callback function that returns a promise, then generates a thunk action creator that dispatches the appropriate lifecycle actions based on the promise's outcome. This separation of concerns makes code easier to read, test, and maintain. By providing a standardized approach to async logic, createAsyncThunk ensures consistent behavior across your application and reduces the potential for bugs that often arise from manual async handling patterns.
This approach represents a significant evolution from legacy Redux patterns. Where developers once wrote substantial boilerplate code for each async operation--creating action type constants, writing action creator functions, implementing try-catch blocks, and manually updating state--createAsyncThunk condenses all of this into a single, declarative function that handles the entire request lifecycle automatically.
Related: Learn how TypeScript patterns for Redux enhance type safety in async operations, and explore our guide on building React applications with modern state management. For teams integrating AI-powered features, understanding these patterns is essential for building reliable API integrations that communicate with external services.
The Async Action Lifecycle
When you dispatch a createAsyncThunk, it progresses through three distinct stages that mirror real-world async operations. Understanding this lifecycle is essential for effectively using the API and integrating it with your reducers.
1. Pending Action
The pending action dispatches immediately when the thunk begins executing, before any async work starts. This action signals that an async operation is in progress and is typically used to update loading state indicators in the UI. Components can listen for this action to show spinners, disable buttons, or display "loading" placeholders. As documented in the Redux Essentials guide, this action enables you to track async state and provide appropriate user feedback throughout the request lifecycle.
2. Fulfilled Action
The fulfilled action dispatches when the promise returned by the payload creator resolves successfully. The resolved value automatically becomes the action's payload, which reducers can use to update application state with the fetched data or confirmation of successful operations. This action indicates that the async work completed without errors and that results are available for use in your components.
3. Rejected Action
The rejected action dispatches when the promise fails--whether due to network errors, server errors, or exceptions thrown in the payload creator. Redux Toolkit automatically serializes any errors that occur, storing them in a standardized format that reducers can access. This action enables applications to gracefully handle failures, display error messages to users, and recover from exceptional conditions.
┌─────────────────────────────────────────────────────────────────┐
│ createAsyncThunk Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ dispatch(fetchUserById(123)) │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ PENDING Action │ ← loading = 'pending' │
│ │ (state.loading) │ UI shows spinner │
│ └─────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Payload Creator │ ← async operation executes │
│ │ (await fetch...) │ │
│ └─────────┬───────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ │ │ │
│ ▼ ▼ │
│ SUCCESS FAILURE │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────────┐ │
│ │FULFILLED │ │ REJECTED │ │
│ │ action │ │ action │ │
│ │payload: │ │ error: │ │
│ │ userData │ │ error_msg │ │
│ └──────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ REDUCER │ │
│ │ Updates state based on action type │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Understanding this three-stage lifecycle enables you to build responsive UIs that accurately reflect the state of asynchronous operations throughout your application. Proper state management patterns like these are foundational to our web development approach, ensuring applications remain performant and maintainable at scale.
1import { createAsyncThunk } from '@reduxjs/toolkit';2 3// Create a thunk that fetches user data4export const fetchUserById = createAsyncThunk(5 'users/fetchById', // Action type prefix6 async (userId: number) => {7 const response = await fetch(`https://api.example.com/users/${userId}`);8 if (!response.ok) {9 throw new Error('Failed to fetch user');10 }11 return response.json(); // This becomes action.payload12 }13);14 15// Dispatching from a component16const handleFetchUser = async () => {17 const result = await dispatch(fetchUserById(123));18 19 if (fetchUserById.fulfilled.match(result)) {20 console.log('User data:', result.payload);21 } else {22 console.error('Error:', result.error);23 }24};Integrating with createSlice using extraReducers
The extraReducers configuration in createSlice handles actions defined outside the slice, including createAsyncThunk lifecycle actions. The builder callback notation provides a clean, chainable syntax for defining state transitions that responds to each lifecycle action from your async thunks.
The builder notation, as covered in the DEV Community guide on async logic with Redux Toolkit, offers several advantages over the older object syntax. It allows for better TypeScript inference, makes it easier to add matchers for common patterns, and provides a more readable structure when handling multiple async actions in a single slice.
For logic that should run regardless of whether the async operation succeeded or failed--such as clearing temporary state or stopping loading indicators--Redux Toolkit provides the settled matcher. This matches both fulfilled and rejected actions, allowing you to write cleanup logic once instead of duplicating it across multiple case handlers.
When building production applications, these patterns integrate seamlessly with our comprehensive testing strategies to ensure reliability across your application stack.
1import { createSlice, PayloadAction } from '@reduxjs/toolkit';2 3interface User {4 id: number;5 name: string;6 email: string;7}8 9interface UserState {10 data: User | null;11 loading: 'idle' | 'pending' | 'succeeded' | 'failed';12 error: string | null;13}14 15const initialState: UserState = {16 data: null,17 loading: 'idle',18 error: null,19};20 21const userSlice = createSlice({22 name: 'users',23 initialState,24 reducers: {25 // Synchronous reducers go here26 clearUser: (state) => {27 state.data = null;28 state.error = null;29 },30 },31 extraReducers: (builder) => {32 builder33 .addCase(fetchUserById.pending, (state) => {34 state.loading = 'pending';35 state.error = null;36 })37 .addCase(fetchUserById.fulfilled, (state, action: PayloadAction<User>) => {38 state.loading = 'succeeded';39 state.data = action.payload;40 })41 .addCase(fetchUserById.rejected, (state, action) => {42 state.loading = 'failed';43 state.error = action.error.message || 'Unknown error';44 })45 // Use settled matcher for cleanup that runs regardless of outcome46 .addMatcher(fetchUserById.settled, (state) => {47 state.loading = 'idle';48 });49 },50});51 52export const { clearUser } = userSlice.actions;53export default userSlice.reducer;Error Handling with rejectWithValue
The rejectWithValue utility from thunkAPI allows you to return custom error payloads instead of relying on Redux Toolkit's default error serialization. This is essential for handling API error responses that include structured error data, as covered in the LogRocket tutorial on createAsyncThunk.
By default, when a payload creator throws an error or returns a rejected promise, createAsyncThunk serializes the error using a standard format. However, this approach has limitations--you cannot easily include custom error data from your API responses, and the serialized error format may not match your application's error handling needs. The rejectWithValue utility solves this by allowing you to return a custom payload for rejected actions, giving you full control over the error structure.
When using rejectWithValue, the custom error data is accessible through action.payload in the rejected case, just like successful responses. This enables reducers to handle different error types appropriately and provides components with rich error information for display to users.
Robust error handling is critical for any production application. Our web development services emphasize defensive programming practices that ensure graceful degradation and clear user feedback when issues occur.
1import { createAsyncThunk } from '@reduxjs/toolkit';2 3interface LoginCredentials {4 email: string;5 password: string;6}7 8interface ApiError {9 code: string;10 message: string;11 details?: Record<string, string[]>;12}13 14interface AuthResponse {15 user: User;16 token: string;17}18 19export const loginUser = createAsyncThunk<20 AuthResponse,21 LoginCredentials,22 { rejectValue: ApiError }23>(24 'auth/login',25 async (credentials, { rejectWithValue }) => {26 const response = await fetch('https://api.example.com/auth/login', {27 method: 'POST',28 headers: { 'Content-Type': 'application/json' },29 body: JSON.stringify(credentials),30 });31 32 const data = await response.json();33 34 if (!response.ok) {35 // Return custom error from API36 return rejectWithValue(data);37 }38 39 return data;40 }41);42 43// In the slice's extraReducers44extraReducers: (builder) => {45 builder46 .addCase(loginUser.fulfilled, (state, action) => {47 state.user = action.payload.user;48 state.token = action.payload.token;49 state.isAuthenticated = true;50 })51 .addCase(loginUser.rejected, (state, action) => {52 if (action.payload) {53 // rejectWithValue was used - rich error data available54 state.errorCode = action.payload.code;55 state.errorMessage = action.payload.message;56 if (action.payload.details) {57 state.validationErrors = action.payload.details;58 }59 } else {60 // Other error (no rejectWithValue)61 state.errorMessage = action.error.message || 'Network error';62 }63 state.isAuthenticated = false;64 });65}Advanced Features
Request Cancellation with AbortController
Cancel in-flight requests when users navigate away or trigger new searches. createAsyncThunk integrates with the browser's AbortController API to support request cancellation. This is essential for preventing state updates on unmounted components and reducing unnecessary network traffic.
Condition Callback
Skip execution based on application state--for example, to prevent duplicate requests for the same data or check authentication status before proceeding. The condition option receives the thunk arguments and the thunkAPI object, allowing you to make informed decisions about whether to proceed with the async operation.
Dispatch Options
When dispatching a thunk, you can pass an optional second argument with configuration options. The signal option allows external control over request cancellation, which is useful when coordinating multiple related requests or integrating with third-party libraries.
For users who trigger the same async operation multiple times--such as search queries or filter changes--implementing these features together prevents unnecessary API calls and reduces server load, improving both user experience and application performance. These optimization patterns are especially important when building high-performance web applications that scale to handle increased traffic.
Applications that rely on external APIs for core functionality benefit from implementing these cancellation patterns to ensure reliability. Whether you're integrating AI services or connecting to third-party data providers, proper request management prevents race conditions and ensures consistent behavior.
1import { createAsyncThunk } from '@reduxjs/toolkit';2 3// Create a thunk with AbortController integration4export const searchProducts = createAsyncThunk(5 'products/search',6 async (query: string, { signal }) => {7 const controller = new AbortController();8 9 // Pass the signal to fetch - allows cancellation10 const response = await fetch(11 `https://api.example.com/products/search?q=${query}`,12 { signal: controller.signal }13 );14 15 return response.json();16 },17 {18 // Prevent duplicate requests19 condition: (query, { getState }) => {20 const { products } = getState();21 22 // Skip if already loading the same query23 if (products.loading === 'pending' && products.searchQuery === query) {24 return false;25 }26 27 // Also check if we already have cached results28 if (products.cachedResults[query]) {29 return false; // Use cached data instead30 }31 },32 }33);34 35// In a React component - cancel on unmount or query change36useEffect(() => {37 const searchTask = dispatch(searchProducts(searchQuery));38 39 return () => {40 searchTask.abort(); // Cancel in-flight request41 };42}, [searchQuery]);43 44// External cancellation with AbortController45const controller = new AbortController();46 47dispatch(fetchData(arg, { signal: controller.signal }));48 49// Later - cancel from outside the thunk50controller.abort();TypeScript Patterns
createAsyncThunk provides full TypeScript support with generic type parameters for argument types, return types, and thunkAPI types. Proper typing ensures type safety throughout your async logic, from the payload creator callback to reducer action handlers, as demonstrated in this comprehensive TypeScript guide.
The generic type parameters work as follows: the first parameter defines the return type (fulfilled payload), the second defines the argument type passed to the payload creator, and the optional third parameter extends the thunkAPI type to include custom rejectValue types. When using rejectWithValue, you define the type of the rejected payload through this third generic parameter.
TypeScript inference flows through the entire async workflow--action creators receive typed arguments, dispatched actions carry typed payloads, and reducers can access strongly-typed data structures. This eliminates an entire category of runtime errors and makes refactoring async code significantly safer.
For teams working on complex applications, combining TypeScript with Redux Toolkit's type inference capabilities significantly reduces debugging time and improves code maintainability. This approach aligns with best practices we follow in all our web development projects, ensuring type-safe codebases that are easier to maintain and extend.
1import { createAsyncThunk } from '@reduxjs/toolkit';2 3interface SearchParams {4 query: string;5 page: number;6 filters?: Record<string, string>;7}8 9interface Product {10 id: string;11 name: string;12 price: number;13 category: string;14}15 16interface SearchResponse {17 products: Product[];18 total: number;19 page: number;20 hasMore: boolean;21}22 23interface ApiError {24 code: string;25 message: string;26}27 28export const searchProducts = createAsyncThunk<29 SearchResponse, // Return type (fulfilled payload)30 SearchParams, // Argument type31 { rejectValue: ApiError } // ThunkAPI type for rejectWithValue32>(33 'products/search',34 async (params, { rejectWithValue }) => {35 try {36 const response = await fetch('/api/products/search', {37 method: 'POST',38 headers: { 'Content-Type': 'application/json' },39 body: JSON.stringify(params),40 });41 42 if (!response.ok) {43 const error: ApiError = await response.json();44 return rejectWithValue(error);45 }46 47 const data: SearchResponse = await response.json();48 return data;49 } catch (error) {50 return rejectWithValue({ 51 code: 'NETWORK_ERROR',52 message: error instanceof Error ? error.message : 'Network error' 53 });54 }55 }56);57 58// In reducer - fully typed access to payload59extraReducers: (builder) => {60 builder61 .addCase(searchProducts.fulfilled, (state, action) => {62 // SearchResponse type is inferred63 state.products = action.payload.products;64 state.totalCount = action.payload.total;65 state.hasMore = action.payload.hasMore;66 })67 .addCase(searchProducts.rejected, (state, action) => {68 if (action.payload) {69 // ApiError type is available70 state.errorCode = action.payload.code;71 state.errorMessage = action.payload.message;72 }73 });74}Use Loading State Enums
Track async state with string literals ('idle' | 'pending' | 'succeeded' | 'failed') rather than booleans for clearer logic and more nuanced UI behavior that accurately reflects the request lifecycle.
Implement Request Deduplication
Use the condition option to prevent duplicate API calls when users trigger the same operation multiple times. This reduces server load and prevents race conditions in your application.
Always Handle Rejected State
Even if you don't expect errors, include rejected case handlers to gracefully handle network failures, unexpected exceptions, and provide meaningful feedback to users.
Clean Up on Unmount
Abort in-flight requests in useEffect cleanup functions to prevent state updates on unmounted components, which can cause memory leaks and unexpected UI behavior.
Frequently Asked Questions
Sources
- Redux Toolkit: createAsyncThunk API - Official API reference documentation
- Redux Essentials: Async Logic and Data Fetching - Official Redux tutorial on async patterns
- LogRocket: Using Redux Toolkit's createAsyncThunk - Practical error handling tutorial
- DEV Community: Mastering Async Logic with createAsyncThunk - TypeScript and builder notation guide