Redux Thunks: A Comprehensive Guide

Understanding Asynchronous Operations in Redux Applications

Introduction to Thunks

Imagine you're at a restaurant. When you place an order (dispatch an action), you don't expect the chef (reducer) to leave the kitchen, go to the market, buy ingredients, and then cook your meal. Instead, a waiter (middleware) takes your order, passes it to the kitchen, and then brings back your food when it's ready.

In Redux applications, thunks play the role of that waiter. They handle asynchronous operations so your reducers can focus solely on updating state.

One of the most common problems you need middleware to solve is asynchronicity. When building web applications that interact with a server, you'll need to request resources and then dispatch the response to your store when it eventually gets back.

While it's possible to make these API calls from your components and dispatch synchronously on success, for consistency and reusability it's preferable to have the source of every change to your application state be an action creator. Thunks are a special kind of action creator that will allow you to do that.

What Are Thunks?

In programming, a "thunk" is a function that wraps an expression to delay its evaluation. In the context of Redux, a thunk is a function that delays the dispatch of an action until a certain condition is met - typically an asynchronous operation like a data fetch.

Historical Note

The term "thunk" comes from a programming technique where a calculation is delayed until it's needed. It was coined in the 1960s as a playful past-tense variant of "think" - as in "the function has already thought about its computation."

How Thunks Work

Rather than returning a plain object, a thunk action creator returns a function. This function, when called with an argument of dispatch, can then dispatch one or more actions, immediately or later.

Think of it like writing a letter (action) versus hiring a courier service (thunk). The letter contains a single message, but the courier service can deliver multiple messages over time and respond to changing conditions.

Basic Thunk Example


// Regular action creator - returns an object
const regularAction = (message) => ({
  type: 'RECEIVE_MESSAGE',
  message
});

// Thunk action creator - returns a function
const thunkActionCreator = () => dispatch => {
  dispatch({
    type: 'RECEIVE_MESSAGE',
    message: 'This will be dispatched immediately.'
  });

  setTimeout(() => dispatch({
    type: 'RECEIVE_MESSAGE',
    message: 'This will be dispatched 1 second later.'
  }), 1000);
}
                

This is great, but without custom middleware it will break as soon as the function action hits your reducer because only POJO (Plain-Old JavaScript) objects with a key of type can be passed into the reducer as actions. You need middleware to intercept all actions of type function before it gets to the reducer. Instead of passing the function action into the reducer, it will invoke it with the Redux store's dispatch and getState methods.

This middleware is called a thunk middleware. It intercepts thunk actions, or functions that have been dispatched.

Thunk Middleware Implementation


const thunk = ({ dispatch, getState }) => next => action => {
  if (typeof action === 'function') {
    // If it's a function, call it with dispatch and getState
    return action(dispatch, getState);
  }
  // Otherwise, continue as normal
  return next(action);
};

export default thunk;
                

Instead of defining your own thunk middleware, you can use the one from the redux-thunk package. This is the recommended approach for most applications.

Applying the Thunk Middleware

To apply the thunk middleware into the store, you can use the applyMiddleware function from redux and use it as a store enhancer when creating the store.

Configuring Store with Thunk


import { createStore, applyMiddleware } from 'redux';
import logger from 'redux-logger';
import thunk from 'redux-thunk';
import rootReducer from './reducers';

const configureStore = (preloadedState = {}) => {
  return createStore(
    rootReducer,
    preloadedState,
    applyMiddleware(thunk, logger),
  );
};

export default configureStore;
                

Notice how the thunk middleware is applied before the logger middleware. This is necessary because the thunk middleware should intercept all thunk actions before they reach the logger middleware since logger only prints out regular POJO actions.

Think of this like a security checkpoint at an airport. The thunk middleware checks if the action is a function (like checking if a passenger has special clearance). If it is, it handles it specially. If not, it passes it along to the next middleware in line.

Thunk Action Creators

A regular POJO action creator is a function that returns a POJO (Plain-Old JavaScript Object) with a type key. A thunk action creator is a function that returns a thunk function. A thunk function is a function that is invoked by the thunk middleware and gets passed the dispatch and getState store methods.

Inside of the thunk function, an asynchronous call is made. After the async call is done, a regular POJO action may be dispatched.

Thunk Action Creator Example


export const RECEIVE_FRUITS = 'RECEIVE_FRUITS';
export const REQUEST_FRUITS = 'REQUEST_FRUITS';
export const RECEIVE_FRUITS_ERROR = 'RECEIVE_FRUITS_ERROR';

// Thunk action creator
export const fetchFruits = () => async (dispatch, getState) => {
  // You can access current state before the request
  const { fruits } = getState();
  
  // Check if we already have fruits and don't need to fetch
  if (fruits && fruits.length > 0) {
    console.log('Using cached fruits');
    return; // Exit early if we already have data
  }
  
  // Dispatch action to indicate loading state
  dispatch({ type: REQUEST_FRUITS });
  
  try {
    const res = await fetch(`/api/fruits`);
    const data = await res.json();
    
    if (res.ok) { // if response status code is less than 400
      // dispatch the receive fruits POJO action
      dispatch(receiveFruits(data.fruits));
    } else {
      // Handle HTTP errors
      dispatch({
        type: RECEIVE_FRUITS_ERROR,
        error: `HTTP Error: ${res.status}`
      });
    }
  } catch (error) {
    // Handle network errors
    dispatch({
      type: RECEIVE_FRUITS_ERROR,
      error: error.message
    });
  }
};

// Regular action creator
const receiveFruits = (fruits) => {
  return {
    type: RECEIVE_FRUITS,
    fruits,
  };
};
                

When fetchFruits is dispatched, it will get intercepted by the thunk middleware and invoked with the dispatch and getState store methods. That thunk function will make an AJAX call to the backend to the route /api/fruits. When the AJAX request comes back, the response is processed. If the response has a status code of less than 400, receiveFruits's action is dispatched using the data from the response. Finally, that action will hit the reducer, change the store, and update the React components.

Advanced Tips

  • You can use the getState parameter to access the current state before making your API call
  • You can dispatch multiple actions from a single thunk
  • You can perform conditional logic to decide whether to make the API call or not
  • You can chain thunks by dispatching other thunk action creators within a thunk

Why Use Thunks?

You might be wondering, "Why use thunks at all? Why not just call APIs directly from components?"

Thunks provide several key benefits:

Separation of Concerns

Components shouldn't need to know how to fetch data. They should just know how to display it. Thunks allow you to abstract away data fetching logic from your UI components.

Centralized Data Flow

By keeping all data fetching in thunks, you have a consistent pattern for how data enters your Redux store.

Easier Testing

Thunks make your application more testable since you can mock the dispatch and getState functions to test your thunk actions in isolation.

Reusability

Thunks can be dispatched from multiple components, avoiding code duplication.

Real-World Example: User Authentication

Let's look at a more complex, real-world example: user authentication. This involves multiple API calls, conditional logic, and managing user state.

Authentication Thunks


// Action Types
export const LOGIN_REQUEST = 'LOGIN_REQUEST';
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAILURE = 'LOGIN_FAILURE';
export const LOGOUT = 'LOGOUT';

// Regular action creators
const requestLogin = () => ({ type: LOGIN_REQUEST });
const receiveLogin = user => ({ type: LOGIN_SUCCESS, user });
const loginError = error => ({ type: LOGIN_FAILURE, error });
export const logout = () => ({ type: LOGOUT });

// Thunk action creator for logging in
export const login = (username, password) => async dispatch => {
  dispatch(requestLogin());

  try {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password })
    });
    
    const data = await response.json();
    
    if (!response.ok) {
      dispatch(loginError(data.message));
      return false;
    }
    
    // Store token in localStorage
    localStorage.setItem('token', data.token);
    
    // Fetch user profile with token
    const profileResponse = await fetch('/api/profile', {
      headers: {
        'Authorization': `Bearer ${data.token}`
      }
    });
    
    const profileData = await profileResponse.json();
    
    if (!profileResponse.ok) {
      dispatch(loginError('Failed to fetch user profile'));
      return false;
    }
    
    // Dispatch success with user data
    dispatch(receiveLogin(profileData.user));
    return true;
  } catch (error) {
    dispatch(loginError('Network error'));
    return false;
  }
};

// Thunk for checking existing session
export const checkAuthStatus = () => async dispatch => {
  const token = localStorage.getItem('token');
  if (!token) return;
  
  try {
    const response = await fetch('/api/profile', {
      headers: {
        'Authorization': `Bearer ${token}`
      }
    });
    
    if (!response.ok) {
      localStorage.removeItem('token');
      return;
    }
    
    const data = await response.json();
    dispatch(receiveLogin(data.user));
  } catch (error) {
    localStorage.removeItem('token');
  }
};

// Thunk for logging out
export const logoutUser = () => dispatch => {
  localStorage.removeItem('token');
  dispatch(logout());
};
                

In this example, our thunks handle several complex operations:

By keeping all this logic in thunks, our components can remain focused on rendering UI, not handling authentication logic.

Dispatching Thunk Actions in Components

Dispatching thunk actions in a component is just like dispatching regular POJO actions. Use the useDispatch React-Redux hook to get the store's dispatch method and dispatch a thunk action on user input or in a useEffect.

Login Component Example


import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { login, checkAuthStatus } from '../actions/auth';

const LoginForm = () => {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [errorMessage, setErrorMessage] = useState('');
  
  const dispatch = useDispatch();
  const { isLoading, error } = useSelector(state => state.auth);
  
  // Check if the user is already logged in when component mounts
  useEffect(() => {
    dispatch(checkAuthStatus());
  }, [dispatch]);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    setErrorMessage('');
    
    if (!username.trim() || !password.trim()) {
      setErrorMessage('Please enter both username and password');
      return;
    }
    
    const success = await dispatch(login(username, password));
    
    if (success) {
      // Redirect or show success message
      console.log('Login successful!');
    }
  };
  
  return (
    

Login

{errorMessage &&
{errorMessage}
} {error &&
{error}
}
setUsername(e.target.value)} disabled={isLoading} />
setPassword(e.target.value)} disabled={isLoading} />
); }; export default LoginForm;

In this example:

Advanced Thunk Patterns

1. Thunk with Parameters


export const fetchProduct = (productId) => async dispatch => {
  dispatch({ type: 'FETCH_PRODUCT_REQUEST', productId });
  try {
    const response = await fetch(`/api/products/${productId}`);
    const data = await response.json();
    dispatch({ type: 'FETCH_PRODUCT_SUCCESS', product: data });
  } catch (error) {
    dispatch({ type: 'FETCH_PRODUCT_FAILURE', error: error.message });
  }
};

// Usage:
dispatch(fetchProduct(123)); // Fetches product with ID 123
                

2. Conditional Thunks


export const fetchProductIfNeeded = (productId) => (dispatch, getState) => {
  const { products } = getState();
  
  // Check if we already have this product in the store
  if (products[productId]) {
    // Product exists, no need to fetch
    return Promise.resolve();
  }
  
  // Product doesn't exist, fetch it
  return dispatch(fetchProduct(productId));
};
                

3. Chaining Thunks


export const purchaseProduct = (productId, quantity) => async (dispatch) => {
  // First fetch the latest product details
  await dispatch(fetchProduct(productId));
  
  // Then add to cart
  dispatch({ type: 'ADD_TO_CART', productId, quantity });
  
  // Then navigate to cart
  dispatch(navigateToCart());
};

export const navigateToCart = () => (dispatch) => {
  // Update route in state
  dispatch({ type: 'NAVIGATE', route: '/cart' });
};
                

4. Error Handling


export const fetchWithErrorHandling = (endpoint) => async (dispatch) => {
  dispatch({ type: 'API_REQUEST_START', endpoint });
  
  try {
    const response = await fetch(endpoint);
    
    // Handle HTTP errors (4xx, 5xx)
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      throw new Error(errorData.message || `HTTP error ${response.status}`);
    }
    
    const data = await response.json();
    dispatch({ type: 'API_REQUEST_SUCCESS', data, endpoint });
    return data;
  } catch (error) {
    // Unified error handling
    dispatch({ type: 'API_REQUEST_FAILURE', error: error.message, endpoint });
    
    // You can also display a notification
    dispatch({ 
      type: 'SHOW_NOTIFICATION', 
      message: `Failed to fetch data: ${error.message}`,
      level: 'error'
    });
    
    // Re-throw for component-level handling if needed
    throw error;
  } finally {
    dispatch({ type: 'API_REQUEST_COMPLETE', endpoint });
  }
};
                

5. Debouncing API Calls


// For search inputs where you don't want to make an API call on every keystroke
let searchTimer = null;

export const debouncedSearch = (searchTerm) => (dispatch) => {
  // Clear any pending search
  if (searchTimer) {
    clearTimeout(searchTimer);
  }
  
  // Update UI immediately to show what user typed
  dispatch({ type: 'UPDATE_SEARCH_TERM', searchTerm });
  
  // Wait 500ms before making the API call
  searchTimer = setTimeout(() => {
    dispatch(performSearch(searchTerm));
  }, 500);
};

const performSearch = (searchTerm) => async (dispatch) => {
  dispatch({ type: 'SEARCH_REQUEST', searchTerm });
  
  try {
    const response = await fetch(`/api/search?q=${encodeURIComponent(searchTerm)}`);
    const data = await response.json();
    dispatch({ type: 'SEARCH_SUCCESS', results: data.results });
  } catch (error) {
    dispatch({ type: 'SEARCH_FAILURE', error: error.message });
  }
};
                

Comparing Thunks to Other Async Approaches

Feature Thunks Redux Saga Redux Observable
Learning Curve Low High High
Bundle Size Small Medium Large
Testing Straightforward Complex but powerful Complex
Complex Flows Challenging Excellent Excellent
Cancellation Manual Built-in Built-in

Thunks are an excellent starting point for handling asynchronous operations in Redux. They're simple to understand and implement, have minimal boilerplate, and are sufficient for most applications. As your application grows in complexity, you might consider more powerful solutions like Redux Saga or Redux Observable, but many large applications continue to use thunks successfully.

Testing Thunks

Thunks are highly testable since they're just functions. Here's how you can test them using Jest:

Testing a Thunk Action Creator


// auth.test.js
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'jest-fetch-mock';
import { login, LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAILURE } from './auth';

// Configure mock store
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);

// Enable fetch mocks
fetchMock.enableMocks();

describe('auth thunks', () => {
  beforeEach(() => {
    fetchMock.resetMocks();
    localStorage.clear();
  });

  it('creates LOGIN_SUCCESS when login is successful', async () => {
    // Mock successful responses for both API calls
    fetchMock.mockResponses(
      [JSON.stringify({ token: 'fake-token' }), { status: 200 }],
      [JSON.stringify({ user: { id: 1, name: 'Test User' } }), { status: 200 }]
    );

    // Expected actions that should be dispatched
    const expectedActions = [
      { type: LOGIN_REQUEST },
      { type: LOGIN_SUCCESS, user: { id: 1, name: 'Test User' } }
    ];

    const store = mockStore({});
    await store.dispatch(login('testuser', 'password'));

    // Check that the correct actions were dispatched
    expect(store.getActions()).toEqual(expectedActions);
    
    // Check that token was saved to localStorage
    expect(localStorage.getItem('token')).toBe('fake-token');
    
    // Check that the API was called with correct parameters
    expect(fetchMock.mock.calls[0][0]).toEqual('/api/login');
    expect(fetchMock.mock.calls[0][1].method).toEqual('POST');
    expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({
      username: 'testuser',
      password: 'password'
    });
  });

  it('creates LOGIN_FAILURE when login fails', async () => {
    // Mock failed login response
    fetchMock.mockResponseOnce(JSON.stringify({ message: 'Invalid credentials' }), { 
      status: 401 
    });

    const expectedActions = [
      { type: LOGIN_REQUEST },
      { type: LOGIN_FAILURE, error: 'Invalid credentials' }
    ];

    const store = mockStore({});
    const result = await store.dispatch(login('testuser', 'wrong-password'));

    // Check return value
    expect(result).toBe(false);
    
    // Check that the correct actions were dispatched
    expect(store.getActions()).toEqual(expectedActions);
    
    // Token should not be in localStorage
    expect(localStorage.getItem('token')).toBeNull();
  });

  // More tests for edge cases, network errors, etc.
});
                

By using libraries like redux-mock-store and jest-fetch-mock, we can thoroughly test our thunks to ensure they dispatch the correct actions and handle various scenarios appropriately.

Thunks in Modern Redux

With the introduction of Redux Toolkit, working with thunks has become even easier. Redux Toolkit includes thunks by default and provides a createAsyncThunk utility to further simplify async logic.

Using createAsyncThunk


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

// Create an async thunk
export const fetchProducts = createAsyncThunk(
  'products/fetchProducts',
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/products');
      if (!response.ok) {
        return rejectWithValue('Server error');
      }
      const data = await response.json();
      return data;
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

// Create a slice with reducers for the thunk's lifecycle actions
const productsSlice = createSlice({
  name: 'products',
  initialState: {
    items: [],
    loading: 'idle',
    error: null
  },
  reducers: {
    // Standard reducers here
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchProducts.pending, (state) => {
        state.loading = 'loading';
      })
      .addCase(fetchProducts.fulfilled, (state, action) => {
        state.loading = 'idle';
        state.items = action.payload;
      })
      .addCase(fetchProducts.rejected, (state, action) => {
        state.loading = 'idle';
        state.error = action.payload;
      });
  }
});

export default productsSlice.reducer;
                

With createAsyncThunk, Redux Toolkit automatically generates the pending, fulfilled, and rejected action types for you and dispatches them at the appropriate times. It also handles error catching and provides helpful utilities like rejectWithValue for customizing error payloads.

Conclusion

Thunks are a powerful pattern for handling asynchronous operations in Redux applications. They strike a balance between simplicity and power, making them suitable for most applications.

Let's recap what we've learned:

Now that you understand thunks, you're ready to build more complex and robust Redux applications that can efficiently handle asynchronous operations.

Further Resources