The Challenge of Asynchronous Logic in Redux
Redux itself is synchronous by design. When you dispatch an action, the reducers immediately process it and update the state. However, real-world applications frequently need to perform asynchronous operations like:
- API calls to fetch or send data
- Reading/writing to local storage
- Interacting with WebSockets
- Performing complex calculations
- Handling authentication flows
Without middleware, Redux doesn't provide a way to handle these asynchronous operations. This has led to several patterns and libraries for managing async logic in Redux applications:
- Redux Thunk: Functions that can dispatch actions and access the store
- Redux Saga: Uses generator functions for complex async flows
- Redux Observable: Uses RxJS for reactive programming patterns
While these approaches work, they often require substantial boilerplate code and can be complex to understand and implement correctly.
Traditional Redux Thunk Approach
Before Redux Toolkit, managing async operations with Redux Thunk looked like this:
// Action Types
const FETCH_POSTS_REQUEST = 'posts/fetchPostsRequest';
const FETCH_POSTS_SUCCESS = 'posts/fetchPostsSuccess';
const FETCH_POSTS_FAILURE = 'posts/fetchPostsFailure';
// Action Creators
const fetchPostsRequest = () => ({
type: FETCH_POSTS_REQUEST
});
const fetchPostsSuccess = (posts) => ({
type: FETCH_POSTS_SUCCESS,
payload: posts
});
const fetchPostsFailure = (error) => ({
type: FETCH_POSTS_FAILURE,
error: error
});
// Async Thunk Action
const fetchPosts = () => async (dispatch) => {
try {
// Dispatch request action
dispatch(fetchPostsRequest());
// Make API call
const response = await fetch('https://api.example.com/posts');
// Check if the request was successful
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
// Parse the JSON response
const data = await response.json();
// Dispatch success action with the data
dispatch(fetchPostsSuccess(data));
} catch (error) {
// Dispatch failure action with the error
dispatch(fetchPostsFailure(error.message));
}
};
// Reducer
const postsReducer = (state = { loading: false, posts: [], error: null }, action) => {
switch (action.type) {
case FETCH_POSTS_REQUEST:
return {
...state,
loading: true,
error: null
};
case FETCH_POSTS_SUCCESS:
return {
...state,
loading: false,
posts: action.payload
};
case FETCH_POSTS_FAILURE:
return {
...state,
loading: false,
error: action.error
};
default:
return state;
}
};
This approach works but involves a lot of boilerplate code, and the pattern must be repeated for each async operation in your application.
Introducing createAsyncThunk
Redux Toolkit's createAsyncThunk function simplifies this process by:
- Automatically creating the pending/fulfilled/rejected action types
- Dispatching these actions based on the promise's state
- Handling common promise errors and rejections
- Providing a consistent pattern for all async operations
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
// Create the async thunk
export const fetchPosts = createAsyncThunk(
'posts/fetchPosts', // action type prefix
async () => {
const response = await fetch('https://api.example.com/posts');
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
return await response.json();
}
);
// This will automatically create and dispatch these actions:
// - posts/fetchPosts/pending
// - posts/fetchPosts/fulfilled
// - posts/fetchPosts/rejected
// Create a slice that handles these actions
const postsSlice = createSlice({
name: 'posts',
initialState: {
items: [],
loading: 'idle',
error: null
},
reducers: {
// Regular reducers here
},
extraReducers: (builder) => {
// Add reducers for the async actions
builder
.addCase(fetchPosts.pending, (state) => {
state.loading = 'loading';
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = 'idle';
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = 'idle';
state.error = action.error.message;
});
}
});
export default postsSlice.reducer;
Real-World Analogy: Package Delivery
Think of createAsyncThunk like a modern package delivery service:
- Booking: When you request a package pickup (dispatch the thunk), the service automatically sends a notification that your pickup is scheduled (pending action)
- Process: The delivery service handles all the logistics of picking up and transporting your package (async operation)
- Outcome: You automatically receive notifications when your package is delivered successfully (fulfilled action) or when there's a delivery problem (rejected action)
- Tracking: The entire process is tracked in a standardized way, regardless of what's in the package or where it's going
Just as a delivery service handles all the complex logistics and provides standardized status updates, createAsyncThunk manages async operations and provides standardized action dispatching.
The createAsyncThunk API
Let's explore the createAsyncThunk API in more detail:
// Basic syntax
const asyncThunk = createAsyncThunk(
typePrefix,
payloadCreator,
options
);
Parameters
- typePrefix (string, required): A string that will be used as the prefix for the generated action types. Typically follows the pattern 'domain/actionName'.
-
payloadCreator (function, required): An async function that returns a promise. It receives two arguments:
- arg: The first argument passed when the thunk action creator is called
- thunkAPI: An object containing helpful functions and properties
- options (object, optional): An object with additional configuration options
Return Value
createAsyncThunk returns an action creator that:
- When called, returns a Promise that will be unwrapped as the return value
- Has
pending,fulfilled, andrejectedproperties that are action creators for those action types - Has a
typePrefixproperty that is the string prefix you provided
The thunkAPI Object
The second parameter passed to your payload creator function is a thunkAPI object with these properties:
export const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId, thunkAPI) => {
// thunkAPI contains:
const {
dispatch, // The Redux store's dispatch method
getState, // A function that returns the current store state
extra, // The "extra argument" passed to the thunk middleware
requestId, // A unique ID for this thunk instance
signal, // An AbortController.signal object for cancellation
rejectWithValue, // A function to create a rejected result with a custom payload
fulfillWithValue // A function to create a fulfilled result with a custom payload
} = thunkAPI;
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
// Use rejectWithValue to return a custom error payload
return thunkAPI.rejectWithValue({
status: response.status,
message: 'Failed to fetch user'
});
}
const userData = await response.json();
return userData;
} catch (error) {
// You can also use rejectWithValue for caught errors
return thunkAPI.rejectWithValue({
message: error.message
});
}
}
);
Using rejectWithValue
rejectWithValue is particularly useful for passing structured error information to your reducers:
// In your thunk
export const loginUser = createAsyncThunk(
'auth/login',
async (credentials, { rejectWithValue }) => {
try {
const response = await fetch('https://api.example.com/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
const data = await response.json();
// API returns error details in a standard format
if (!response.ok) {
// Return the error data from the API
return rejectWithValue({
status: response.status,
data: data,
timestamp: Date.now()
});
}
return data;
} catch (error) {
// Handle network errors or other unexpected errors
return rejectWithValue({
status: 'NETWORK_ERROR',
message: error.message,
timestamp: Date.now()
});
}
}
);
// In your slice
const authSlice = createSlice({
name: 'auth',
initialState: {
user: null,
loading: 'idle',
error: null
},
reducers: { /* ... */ },
extraReducers: (builder) => {
builder
.addCase(loginUser.rejected, (state, action) => {
state.loading = 'idle';
// Access the custom error payload
if (action.payload) {
// This is the rejection from rejectWithValue
const { status, data, message, timestamp } = action.payload;
if (status === 401) {
state.error = 'Invalid credentials';
} else if (status === 'NETWORK_ERROR') {
state.error = `Connection error: ${message}`;
} else {
state.error = data?.message || 'Login failed';
}
// Log the error for analytics
console.log(`Auth error at ${new Date(timestamp).toISOString()}: ${status}`);
} else {
// This is a rejection from a thrown error in the thunk
state.error = action.error.message;
}
});
}
});
Using rejectWithValue in this way lets you provide standardized, structured error information to your reducers, making error handling more consistent and powerful.
Working with Arguments
Your thunk's payload creator can accept arguments when the thunk is dispatched:
// Create a thunk that accepts arguments
export const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId, thunkAPI) => {
const response = await fetch(`https://api.example.com/users/${userId}`);
return await response.json();
}
);
// In a component, dispatch with an argument
dispatch(fetchUserById(123));
// You can pass complex objects too
export const updateUser = createAsyncThunk(
'users/update',
async ({ userId, userData }, thunkAPI) => {
const response = await fetch(`https://api.example.com/users/${userId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(userData)
});
return await response.json();
}
);
// When dispatching, pass the object
dispatch(updateUser({ userId: 123, userData: { name: 'Alice', email: 'alice@example.com' } }));
Using getState to access the current store state
You can use the getState function from thunkAPI to access the current store state:
export const fetchPostsForCurrentUser = createAsyncThunk(
'posts/fetchForCurrentUser',
async (_, { getState }) => {
// Access the current auth state to get the user ID
const state = getState();
const userId = state.auth.user?.id;
if (!userId) {
throw new Error('No user is logged in');
}
// Use the user ID in the request
const response = await fetch(`https://api.example.com/users/${userId}/posts`);
return await response.json();
}
);
// When dispatching, no argument is needed (using _ as a placeholder)
dispatch(fetchPostsForCurrentUser());
Using dispatch to dispatch additional actions
You can use the dispatch function to dispatch additional actions during your thunk:
export const checkoutCart = createAsyncThunk(
'cart/checkout',
async (_, { dispatch, getState }) => {
const state = getState();
const { cart, auth } = state;
if (!auth.user) {
// Dispatch an action to show a login modal
dispatch(uiActions.showModal('login'));
throw new Error('You must be logged in to checkout');
}
if (cart.items.length === 0) {
throw new Error('Your cart is empty');
}
// Process the order
const response = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${auth.token}`
},
body: JSON.stringify({
items: cart.items,
shippingAddress: auth.user.shippingAddress,
paymentMethod: auth.user.defaultPaymentMethod
})
});
const order = await response.json();
// Dispatch an action to clear the cart after successful checkout
dispatch(cartActions.clearCart());
// Show a success notification
dispatch(uiActions.showNotification({
type: 'success',
message: `Order #${order.id} placed successfully!`
}));
return order;
}
);
Real-World Example: User Registration Flow
Let's see how a complex user registration flow might be implemented with createAsyncThunk:
export const registerUser = createAsyncThunk(
'auth/register',
async (userData, { dispatch, rejectWithValue }) => {
try {
// Step 1: Check if username is available
const checkResponse = await fetch(
`https://api.example.com/users/check-username?username=${userData.username}`
);
const checkData = await checkResponse.json();
if (!checkData.available) {
return rejectWithValue({
field: 'username',
message: 'Username is already taken'
});
}
// Step 2: Register the user
const registerResponse = await fetch('https://api.example.com/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
if (!registerResponse.ok) {
const errorData = await registerResponse.json();
return rejectWithValue(errorData);
}
const newUser = await registerResponse.json();
// Step 3: Log the new user in automatically
dispatch(
loginUser({
username: userData.username,
password: userData.password
})
);
// Step 4: Show a welcome notification
dispatch(
uiActions.showNotification({
type: 'success',
title: 'Welcome!',
message: `Thanks for joining us, ${newUser.firstName}!`
})
);
// Step 5: Track the registration in analytics
dispatch(
analyticsActions.trackEvent({
category: 'User',
action: 'Register',
label: 'New Registration'
})
);
return newUser;
} catch (error) {
return rejectWithValue({
message: 'Registration failed. Please try again.'
});
}
}
);
This example demonstrates how createAsyncThunk can orchestrate a complex flow involving multiple API calls, conditional logic, and dispatching multiple related actions.
Handling Async Lifecycle in Reducers
To handle the actions dispatched by createAsyncThunk, you'll use the extraReducers field in createSlice:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
const response = await fetch('https://api.example.com/users');
return await response.json();
});
const usersSlice = createSlice({
name: 'users',
initialState: {
entities: [],
loading: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null
},
reducers: {
// Regular reducers here
},
extraReducers: (builder) => {
// Add reducers for additional action types
// fetchUsers.pending = 'users/fetchUsers/pending'
builder.addCase(fetchUsers.pending, (state) => {
// When the request starts
state.loading = 'loading';
state.error = null;
});
// fetchUsers.fulfilled = 'users/fetchUsers/fulfilled'
builder.addCase(fetchUsers.fulfilled, (state, action) => {
// When the request succeeds
state.loading = 'succeeded';
state.entities = action.payload;
});
// fetchUsers.rejected = 'users/fetchUsers/rejected'
builder.addCase(fetchUsers.rejected, (state, action) => {
// When the request fails
state.loading = 'failed';
state.error = action.error.message;
// If there's a custom error payload from rejectWithValue
if (action.payload) {
state.error = action.payload.message;
}
});
}
});
export default usersSlice.reducer;
Loading States Pattern
A common pattern for tracking loading states is to use a string field with these values:
'idle': No request has been made yet'loading': A request is in progress'succeeded': The request was successful'failed': The request failed
This approach makes it easy to check and display the current status in your components:
function UsersList() {
const dispatch = useDispatch();
const { entities, loading, error } = useSelector(state => state.users);
useEffect(() => {
// Fetch users only if we haven't already
if (loading === 'idle') {
dispatch(fetchUsers());
}
}, [loading, dispatch]);
// Render based on the current status
if (loading === 'loading') {
return <div>Loading users...</div>;
} else if (loading === 'failed') {
return <div>Error: {error}</div>;
} else if (loading === 'succeeded') {
return (
<ul>
{entities.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
return <div>No users loaded yet.</div>;
}
Request Status Tracking
For applications with many API calls, you might want to track the status of each request separately:
const initialState = {
entities: {},
status: {
fetchAll: 'idle',
fetchById: {}, // Will hold status by ID: { 123: 'loading', 456: 'succeeded' }
create: 'idle',
update: {}, // Will hold status by ID
delete: {} // Will hold status by ID
},
errors: {
fetchAll: null,
fetchById: {},
create: null,
update: {},
delete: {}
}
};
// In your slice's extraReducers
builder
.addCase(fetchUsers.pending, (state) => {
state.status.fetchAll = 'loading';
state.errors.fetchAll = null;
})
// ...more cases for fetchUsers
.addCase(fetchUserById.pending, (state, action) => {
// action.meta.arg contains the original argument (userId)
const userId = action.meta.arg;
state.status.fetchById[userId] = 'loading';
state.errors.fetchById[userId] = null;
})
// ...more cases for fetchUserById
Generic Request Status Management
For large applications with many API calls, you might create a utility function to consistently manage request status:
// Helper function to update request status
function updateRequestStatus(state, requestName, argId = null, status, error = null) {
if (argId === null) {
// For requests without an ID argument
state.status[requestName] = status;
state.errors[requestName] = error;
} else {
// For requests with an ID argument
if (!state.status[requestName]) {
state.status[requestName] = {};
}
if (!state.errors[requestName]) {
state.errors[requestName] = {};
}
state.status[requestName][argId] = status;
state.errors[requestName][argId] = error;
}
}
// In extraReducers:
builder
.addCase(fetchUserById.pending, (state, action) => {
const userId = action.meta.arg;
updateRequestStatus(state, 'fetchById', userId, 'loading');
})
.addCase(fetchUserById.fulfilled, (state, action) => {
const userId = action.meta.arg;
updateRequestStatus(state, 'fetchById', userId, 'succeeded');
// Update entities
state.entities[userId] = action.payload;
})
.addCase(fetchUserById.rejected, (state, action) => {
const userId = action.meta.arg;
updateRequestStatus(
state,
'fetchById',
userId,
'failed',
action.payload?.message || action.error.message
);
});
This approach creates a consistent pattern for handling all your API request statuses and errors, making your code more maintainable as your application grows.
Advanced Techniques
Cancellation and Abortion
createAsyncThunk supports cancellation using AbortController. This is useful for:
- Cancelling requests when the component unmounts
- Cancelling pending requests when a new request is made
- Implementing user-initiated cancellation
// In your thunk, check for cancellation
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { signal }) => {
// Create a fetch request that can be aborted
const response = await fetch('https://api.example.com/users', {
signal // Pass the AbortController.signal to fetch
});
// This will throw an AbortError if the request was cancelled
return await response.json();
}
);
// In your component, use AbortController
function UsersList() {
const dispatch = useDispatch();
useEffect(() => {
// Create an AbortController to manage cancellation
const abortController = new AbortController();
// Dispatch the thunk with the abort signal
const promise = dispatch(fetchUsers());
// When the component unmounts, abort the request
return () => {
abortController.abort();
};
}, [dispatch]);
// Rest of component
}
Conditional Execution
You might not always want to execute a thunk if certain conditions are met. You can use the condition option for this:
// Create a thunk with a condition check
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { getState }) => {
const response = await fetch('https://api.example.com/users');
return await response.json();
},
{
// Only fetch if we don't already have users
condition: (_, { getState }) => {
const { users } = getState();
// Skip if users are already loaded or loading
const fetchStatus = users.status.fetchAll;
if (fetchStatus === 'succeeded' || fetchStatus === 'loading') {
// Returning false cancels the thunk
return false;
}
// Returning true means the thunk will execute
return true;
}
}
);
// In your component, you can always dispatch without worrying
// about whether the fetch should happen
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
Handling Dependent Requests
Sometimes you need to chain multiple API calls, where each call depends on the result of the previous one:
export const fetchUserWithPosts = createAsyncThunk(
'users/fetchWithPosts',
async (userId, { dispatch, getState }) => {
// First, fetch the user
const userResponse = await dispatch(fetchUserById(userId)).unwrap();
// Now fetch their posts using the user data
const postsResponse = await dispatch(fetchPostsByAuthor(userResponse.username)).unwrap();
// Return combined data
return {
user: userResponse,
posts: postsResponse
};
}
);
// Using .unwrap() in a component
const handleFetchUserWithPosts = async (userId) => {
try {
// .unwrap() returns a promise that unwraps to the fulfilled value
// or throws if rejected
const resultData = await dispatch(fetchUserWithPosts(userId)).unwrap();
console.log('Success:', resultData);
} catch (error) {
console.error('Failed to fetch user with posts:', error);
}
};
Handling Optimistic Updates
For a better user experience, you sometimes want to update the UI before the API call completes (optimistic updates), then revert if the call fails:
export const toggleTodoStatus = createAsyncThunk(
'todos/toggleStatus',
async (todoId, { dispatch, getState, rejectWithValue }) => {
const { todos } = getState();
const todo = todos.entities.find(todo => todo.id === todoId);
if (!todo) {
return rejectWithValue('Todo not found');
}
// Save the original todo before making changes
const originalTodo = { ...todo };
// Optimistically update the todo in the store
dispatch(todosSlice.actions.updateTodo({
id: todoId,
changes: { completed: !todo.completed }
}));
try {
// Make the API call to update on the server
const response = await fetch(`https://api.example.com/todos/${todoId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ completed: !todo.completed })
});
if (!response.ok) {
throw new Error('Failed to update todo');
}
return await response.json();
} catch (error) {
// If the API call fails, revert to the original state
dispatch(todosSlice.actions.updateTodo({
id: todoId,
changes: { completed: originalTodo.completed }
}));
// Return the error
return rejectWithValue(error.message);
}
}
);
// The corresponding slice doesn't need to handle the async actions
// since we're using direct actions for optimistic updates
const todosSlice = createSlice({
name: 'todos',
initialState: {
entities: []
},
reducers: {
updateTodo: (state, action) => {
const { id, changes } = action.payload;
const todo = state.entities.find(todo => todo.id === id);
if (todo) {
Object.assign(todo, changes);
}
}
}
});
Real-World Example: Shopping Cart Checkout
Let's examine a complete checkout flow that combines many of these advanced techniques:
// In cartSlice.js
export const checkout = createAsyncThunk(
'cart/checkout',
async (_, { getState, dispatch, rejectWithValue }) => {
const { cart, auth } = getState();
// Check if user is logged in
if (!auth.user) {
dispatch(uiActions.openModal('login'));
return rejectWithValue('Please log in to checkout');
}
// Check if cart is empty
if (cart.items.length === 0) {
return rejectWithValue('Your cart is empty');
}
// Save the current cart for potential rollback
const originalCart = { ...cart };
try {
// Step 1: Validate cart items are in stock
dispatch(uiActions.showLoading('Verifying inventory...'));
const stockResponse = await fetch('/api/inventory/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: cart.items.map(item => ({
productId: item.productId,
quantity: item.quantity
})) })
});
const stockData = await stockResponse.json();
if (!stockResponse.ok) {
return rejectWithValue(stockData.message);
}
if (stockData.outOfStock.length > 0) {
return rejectWithValue(
`Some items are out of stock: ${stockData.outOfStock.map(item => item.name).join(', ')}`
);
}
// Step 2: Process payment
dispatch(uiActions.showLoading('Processing payment...'));
// Optimistically update UI to show order is processing
dispatch(cartActions.setStatus('processing'));
const paymentResponse = await fetch('/api/payments/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: cart.totalAmount,
currency: 'USD',
paymentMethod: auth.user.defaultPaymentMethod,
items: cart.items
})
});
const paymentData = await paymentResponse.json();
if (!paymentResponse.ok) {
return rejectWithValue(`Payment failed: ${paymentData.message}`);
}
// Step 3: Create order
dispatch(uiActions.showLoading('Creating your order...'));
const orderResponse = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: cart.items,
paymentId: paymentData.paymentId,
shippingAddress: auth.user.shippingAddress
})
});
const orderData = await orderResponse.json();
if (!orderResponse.ok) {
// If order creation fails, we need to refund the payment
dispatch(refundPayment(paymentData.paymentId));
return rejectWithValue(`Order creation failed: ${orderData.message}`);
}
// Step 4: Empty the cart and show success
dispatch(cartActions.clearCart());
dispatch(uiActions.hideLoading());
dispatch(uiActions.showNotification({
type: 'success',
title: 'Order Placed!',
message: `Your order #${orderData.orderId} has been placed successfully.`
}));
// Step 5: Track the purchase in analytics
dispatch(analyticsActions.trackPurchase({
orderId: orderData.orderId,
revenue: cart.totalAmount,
tax: cart.taxAmount,
shipping: cart.shippingCost,
items: cart.items
}));
return orderData;
} catch (error) {
// In case of network errors, revert cart to original state
dispatch(cartActions.restoreCart(originalCart));
return rejectWithValue('Network error during checkout. Please try again.');
} finally {
// Always hide loading indicator when done
dispatch(uiActions.hideLoading());
}
},
{
// Only allow one checkout process at a time
condition: (_, { getState }) => {
const { cart } = getState();
return cart.status !== 'processing';
}
}
);
// In the cart slice
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
totalAmount: 0,
taxAmount: 0,
shippingCost: 0,
status: 'idle' // 'idle' | 'processing' | 'completed'
},
reducers: {
addItem: (state, action) => { /* ... */ },
removeItem: (state, action) => { /* ... */ },
updateQuantity: (state, action) => { /* ... */ },
clearCart: (state) => {
state.items = [];
state.totalAmount = 0;
state.taxAmount = 0;
state.shippingCost = 0;
state.status = 'completed';
},
setStatus: (state, action) => {
state.status = action.payload;
},
restoreCart: (state, action) => {
// Restore cart from a saved state
return { ...action.payload, status: 'idle' };
}
},
extraReducers: (builder) => {
builder
.addCase(checkout.pending, (state) => {
state.status = 'processing';
})
.addCase(checkout.fulfilled, (state) => {
// Cart is already cleared in the thunk
state.status = 'completed';
})
.addCase(checkout.rejected, (state, action) => {
state.status = 'idle';
// Show the error in a notification (handled in UI slice)
});
}
});
This example demonstrates a complex checkout flow handling validation, payment processing, order creation, and analytics tracking, with optimistic updates and error handling at each step.
Testing Async Thunks
Testing async thunks requires mocking the API calls and verifying that the correct actions are dispatched:
// users.test.js
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'jest-fetch-mock';
import { fetchUsers } from './usersSlice';
// Create a mock store with the thunk middleware
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
// Setup fetch mock
fetchMock.enableMocks();
describe('async actions', () => {
beforeEach(() => {
fetchMock.resetMocks();
});
it('creates FETCH_USERS_SUCCESS when fetching users has been done', async () => {
// Mock data to be returned by the API
const mockUsers = [{ id: 1, name: 'John Doe' }];
// Setup the mock response
fetchMock.mockResponseOnce(JSON.stringify(mockUsers));
// Create a mock store with initial state
const store = mockStore({ users: { entities: [], loading: 'idle', error: null } });
// Dispatch the action creator
await store.dispatch(fetchUsers());
// Check that the correct actions were fired
const actions = store.getActions();
// Should have pending and fulfilled actions
expect(actions[0].type).toEqual(fetchUsers.pending.type);
expect(actions[1].type).toEqual(fetchUsers.fulfilled.type);
expect(actions[1].payload).toEqual(mockUsers);
});
it('creates FETCH_USERS_FAILURE when fetching users fails', async () => {
// Setup the mock to reject with an error
fetchMock.mockRejectOnce(new Error('Network Error'));
// Create a mock store
const store = mockStore({ users: { entities: [], loading: 'idle', error: null } });
// Dispatch the action creator
await store.dispatch(fetchUsers());
// Check that the correct actions were fired
const actions = store.getActions();
// Should have pending and rejected actions
expect(actions[0].type).toEqual(fetchUsers.pending.type);
expect(actions[1].type).toEqual(fetchUsers.rejected.type);
expect(actions[1].error.message).toEqual('Network Error');
});
});
Testing the Reducer
You should also test how your reducer handles the actions dispatched by the thunk:
// usersReducer.test.js
import reducer, { initialState } from './usersSlice';
import { fetchUsers } from './usersSlice';
describe('users reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(initialState);
});
it('should handle fetchUsers.pending', () => {
const action = { type: fetchUsers.pending.type };
const state = reducer(initialState, action);
expect(state.loading).toEqual('loading');
expect(state.error).toBeNull();
});
it('should handle fetchUsers.fulfilled', () => {
const mockUsers = [{ id: 1, name: 'John Doe' }];
const action = {
type: fetchUsers.fulfilled.type,
payload: mockUsers
};
const state = reducer(initialState, action);
expect(state.loading).toEqual('succeeded');
expect(state.entities).toEqual(mockUsers);
expect(state.error).toBeNull();
});
it('should handle fetchUsers.rejected', () => {
const action = {
type: fetchUsers.rejected.type,
error: { message: 'Failed to fetch' }
};
const state = reducer(initialState, action);
expect(state.loading).toEqual('failed');
expect(state.error).toEqual('Failed to fetch');
});
});
Testing the Thunk Payload Creator Directly
For more detailed tests, you can test the payload creator function directly:
// Direct testing of the payload creator function
it('fetchUsers payload creator should return user data', async () => {
// Get the payload creator function
const payloadCreator = fetchUsers.fulfilled.type.split('/')[2];
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue([{ id: 1, name: 'John Doe' }])
});
// Mock thunkAPI
const thunkAPI = {
dispatch: jest.fn(),
getState: jest.fn(),
};
// Call the payload creator directly
const result = await payloadCreator(null, thunkAPI);
// Check the result
expect(result).toEqual([{ id: 1, name: 'John Doe' }]);
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/users');
});
Practice Activities
Activity 1: Basic Async Thunk Implementation
Create a posts feature with the following components:
- A posts slice with initial state for posts, loading status, and errors
- A
fetchPostsasync thunk that retrieves posts from an API - Proper handling of pending, fulfilled, and rejected states in the reducer
- A React component that dispatches the thunk and displays the posts
Use the JSONPlaceholder API (https://jsonplaceholder.typicode.com/posts) for your data source.
Activity 2: Advanced Thunk Operations
Extend your posts feature to include:
- A
fetchPostByIdthunk that gets a single post by ID - A
fetchPostWithCommentsthunk that chains multiple API calls (get post, then get comments) - A
createPostthunk that sends a POST request to create a new post - Error handling with
rejectWithValuefor structured error information - Conditional execution to avoid unnecessary API calls
Activity 3: Complete Application with Async Logic
Build a mini blog application with:
- Authentication (login/logout) with async thunks
- Posts management (fetch, create, update, delete) with proper loading states
- Comments functionality with optimistic updates
- Error handling and notifications for failed operations
- A global loading indicator that shows during any async operation
- Cache management to avoid redundant API calls
Implement at least one complex flow that combines multiple thunks and actions.
Summary
- Redux is synchronous by design, but real applications need asynchronous operations
createAsyncThunksimplifies handling async logic in Redux applications- It automatically creates and dispatches pending/fulfilled/rejected actions
- The thunkAPI object provides helpful utilities like dispatch, getState, and rejectWithValue
- Use extraReducers in createSlice to handle async action types
- Loading states can be tracked consistently with status fields like 'idle', 'loading', etc.
- Advanced techniques include request cancellation, conditional execution, and optimistic updates
- Testing async thunks involves mocking API calls and checking dispatched actions