Asynchronous Actions with createAsyncThunk

Managing API Calls and Side Effects in Redux Toolkit

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:

sequenceDiagram participant C as Component participant S as Store participant R as Reducer participant A as API C->>S: Dispatch Action S->>R: Forward Action R->>S: Return New State S->>C: Update with New State Note over C,A: But what about async operations? C->>A: Fetch Data (API call) A-->>C: Response (later) C->>S: Dispatch Action with Result Note over C,S: This leads to messy component code!

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:

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:


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;
        
sequenceDiagram participant C as Component participant D as Dispatch participant AT as createAsyncThunk participant S as Slice participant A as API C->>D: dispatch(fetchPosts()) D->>AT: Execute thunk AT->>D: dispatch(fetchPosts.pending) D->>S: handle pending action S-->>C: State update (loading=true) AT->>A: API call A-->>AT: Response alt Success AT->>D: dispatch(fetchPosts.fulfilled, payload) D->>S: handle fulfilled action S-->>C: State update (data loaded) else Error AT->>D: dispatch(fetchPosts.rejected, error) D->>S: handle rejected action S-->>C: State update (show error) end

Real-World Analogy: Package Delivery

Think of createAsyncThunk like a modern package delivery service:

  1. Booking: When you request a package pickup (dispatch the thunk), the service automatically sends a notification that your pickup is scheduled (pending action)
  2. Process: The delivery service handles all the logistics of picking up and transporting your package (async operation)
  3. Outcome: You automatically receive notifications when your package is delivered successfully (fulfilled action) or when there's a delivery problem (rejected action)
  4. 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

Return Value

createAsyncThunk returns an action creator that:

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:

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:


// 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:

  1. A posts slice with initial state for posts, loading status, and errors
  2. A fetchPosts async thunk that retrieves posts from an API
  3. Proper handling of pending, fulfilled, and rejected states in the reducer
  4. 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:

  1. A fetchPostById thunk that gets a single post by ID
  2. A fetchPostWithComments thunk that chains multiple API calls (get post, then get comments)
  3. A createPost thunk that sends a POST request to create a new post
  4. Error handling with rejectWithValue for structured error information
  5. Conditional execution to avoid unnecessary API calls

Activity 3: Complete Application with Async Logic

Build a mini blog application with:

  1. Authentication (login/logout) with async thunks
  2. Posts management (fetch, create, update, delete) with proper loading states
  3. Comments functionality with optimistic updates
  4. Error handling and notifications for failed operations
  5. A global loading indicator that shows during any async operation
  6. Cache management to avoid redundant API calls

Implement at least one complex flow that combines multiple thunks and actions.

Summary

Further Resources