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:
- Login authentication with username/password
- Storing the JWT token in localStorage
- Fetching the user profile with the token
- Checking if a user is already logged in when the app loads
- Logging out by clearing the token
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 (
);
};
export default LoginForm;
In this example:
- We dispatch the checkAuthStatus thunk when the component mounts to see if the user is already logged in
- When the user submits the form, we dispatch the login thunk with the username and password
- We use the return value from the thunk to determine if login was successful
- We use useSelector to access auth state for displaying loading and error states
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:
- Thunks are functions that delay the execution of an action until a certain condition is met
- They enable you to dispatch actions asynchronously, such as after API calls complete
- Thunks keep your components clean by moving data-fetching logic out of them
- They are implemented using middleware that intercepts function actions
- Thunks can call dispatch multiple times, allowing for complex flows
- Redux Toolkit provides utilities to make working with thunks even easier
Now that you understand thunks, you're ready to build more complex and robust Redux applications that can efficiently handle asynchronous operations.