How to Optimize Redux Thunk in React

This article provides a practical guide on how to optimize Redux Thunk in your React applications. You will learn actionable techniques to improve performance, prevent redundant API requests, handle rapid user inputs, and leverage modern Redux Toolkit features to keep your asynchronous state management fast and clean.

1. Implement Conditional Dispatching

One of the easiest ways to optimize Redux Thunk is to prevent unnecessary API calls by checking the current Redux state before fetching new data. If the data is already cached and still valid, you can bypass the network request entirely.

export const fetchUserData = (userId) => {
  return async (dispatch, getState) => {
    const { users } = getState();
    
    // Skip fetching if the user data already exists in the state
    if (users.byIds[userId]) {
      return; 
    }

    dispatch(fetchUserStart());
    try {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      dispatch(fetchUserSuccess(data));
    } catch (error) {
      dispatch(fetchUserFailure(error.message));
    }
  };
};

2. Use Redux Toolkit’s createAsyncThunk

If you are using modern Redux, you should use createAsyncThunk from Redux Toolkit (RTK). It automatically handles action creators for pending, fulfilled, and rejected states. More importantly, it features a built-in condition option to handle conditional dispatching cleanly.

import { createAsyncThunk } from '@reduxjs/toolkit';

export const fetchPostById = createAsyncThunk(
  'posts/fetchById',
  async (postId) => {
    const response = await fetch(`/api/posts/${postId}`);
    return response.json();
  },
  {
    // Prevent execution if the post is already loading or loaded
    condition: (postId, { getState }) => {
      const { posts } = getState();
      const fetchStatus = posts.status[postId];
      if (fetchStatus === 'pending' || fetchStatus === 'fulfilled') {
        return false;
      }
    },
  }
);

3. Cancel Outdated Requests with AbortController

In fast-paced applications, a user might trigger multiple asynchronous actions in quick succession (e.g., clicking pagination buttons quickly). To avoid race conditions and save bandwidth, use an AbortController to cancel active, outstanding network requests.

With createAsyncThunk, an abort signal is automatically passed to your payload creator:

export const searchProducts = createAsyncThunk(
  'products/search',
  async (query, { signal }) => {
    const response = await fetch(`/api/products?q=${query}`, { signal });
    return response.json();
  }
);

// In your component, you can abort the promise if the query changes
useEffect(() => {
  const promise = dispatch(searchProducts(query));
  
  return () => {
    promise.abort(); // Cancels the previous fetch request
  };
}, [query, dispatch]);

4. Debounce and Throttle Dispatch Actions

When triggering thunks via user input (like a search bar or window resizing), wrapping your dispatch calls in a debounce or throttle function prevents Redux Thunk from firing dozens of API requests per second.

import debounce from 'lodash.debounce';

const handleInputChange = debounce((value) => {
  dispatch(fetchSearchResults(value));
}, 300);

5. Normalize Your State Structure

Deeply nested API responses require complex selector logic and can cause unnecessary component re-renders. Optimize your Redux store by normalizing your data using tools like normalizr or Redux Toolkit’s createEntityAdapter.

A normalized state ensures that when a thunk updates a single item, only the components subscribed to that specific item re-render, significantly boosting UI performance.