Introduction to Redux Thunk
Redux Thunk is middleware that allows you to write action creators that return a function instead of an action object. This function can perform asynchronous operations and dispatch actions when ready.
🎭 The Theater Director Analogy
Think of Redux Thunk as a theater director:
- Regular Action: A simple script with one scene
- Thunk Action: A complex play with multiple acts
- Dispatch: The stage where actions perform
- Async Operations: Behind-the-scenes preparation
Just as a director coordinates multiple scenes and actors over time, a thunk coordinates multiple dispatches and async operations.
How Redux Thunk Works
graph TD
A[Action Creator] --> B{Returns Function?}
B -->|No| C[Normal Action]
B -->|Yes| D[Thunk Function]
C --> E[Reducer]
D --> F[Execute Function]
F --> G[Async Operation]
G --> H[Dispatch Action]
H --> E
F --> I[Dispatch Another Action]
I --> E
style B fill:#f96
style D fill:#9cf
style G fill:#9f9
The Thunk Middleware
// Simplified implementation of Redux Thunk
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
// If action is a function, call it with dispatch and getState
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
// Otherwise, pass it to the next middleware
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
// How it's used in store setup
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';
const store = createStore(
rootReducer,
applyMiddleware(thunk)
);
Basic Thunk Patterns
1. Simple Async Action
// Regular action creators
const fetchUsersRequest = () => ({
type: 'FETCH_USERS_REQUEST'
});
const fetchUsersSuccess = (users) => ({
type: 'FETCH_USERS_SUCCESS',
payload: users
});
const fetchUsersFailure = (error) => ({
type: 'FETCH_USERS_FAILURE',
payload: error
});
// Thunk action creator
const fetchUsers = () => {
return async (dispatch) => {
dispatch(fetchUsersRequest());
try {
const response = await fetch('/api/users');
const users = await response.json();
dispatch(fetchUsersSuccess(users));
} catch (error) {
dispatch(fetchUsersFailure(error.message));
}
};
};
// Usage in component
const UserList = () => {
const dispatch = useDispatch();
const { users, loading, error } = useSelector(state => state.users);
useEffect(() => {
dispatch(fetchUsers());
}, [dispatch]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
2. Thunk with Parameters
// Thunk that accepts parameters
const fetchUserById = (userId) => {
return async (dispatch) => {
dispatch({ type: 'FETCH_USER_REQUEST' });
try {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
dispatch({
type: 'FETCH_USER_SUCCESS',
payload: user
});
} catch (error) {
dispatch({
type: 'FETCH_USER_FAILURE',
payload: error.message
});
}
};
};
// Thunk that uses getState
const fetchRelatedPosts = (postId) => {
return async (dispatch, getState) => {
const state = getState();
const { user } = state.auth;
if (!user) {
dispatch({
type: 'FETCH_RELATED_POSTS_FAILURE',
payload: 'Must be logged in'
});
return;
}
dispatch({ type: 'FETCH_RELATED_POSTS_REQUEST' });
try {
const response = await fetch(`/api/posts/${postId}/related`, {
headers: {
'Authorization': `Bearer ${user.token}`
}
});
const posts = await response.json();
dispatch({
type: 'FETCH_RELATED_POSTS_SUCCESS',
payload: posts
});
} catch (error) {
dispatch({
type: 'FETCH_RELATED_POSTS_FAILURE',
payload: error.message
});
}
};
};
Advanced Thunk Patterns
1. Conditional Dispatching
// Only fetch if data is not already loaded
const fetchPostsIfNeeded = () => {
return (dispatch, getState) => {
const { posts } = getState();
if (posts.items.length === 0 && !posts.loading) {
return dispatch(fetchPosts());
}
// Return resolved promise if no fetch needed
return Promise.resolve();
};
};
// Fetch with cache check
const fetchWithCache = (resource, id) => {
return (dispatch, getState) => {
const state = getState();
const cached = state.cache[`${resource}:${id}`];
if (cached && Date.now() - cached.timestamp < 5 * 60 * 1000) {
// Use cached data if less than 5 minutes old
dispatch({
type: `FETCH_${resource.toUpperCase()}_SUCCESS`,
payload: cached.data
});
return Promise.resolve(cached.data);
}
// Otherwise fetch fresh data
return dispatch(fetchResource(resource, id));
};
};
2. Chained Async Actions
// Sequential API calls
const createUserAndProfile = (userData, profileData) => {
return async (dispatch) => {
dispatch({ type: 'CREATE_USER_REQUEST' });
try {
// First create the user
const userResponse = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
const user = await userResponse.json();
dispatch({
type: 'CREATE_USER_SUCCESS',
payload: user
});
// Then create their profile
dispatch({ type: 'CREATE_PROFILE_REQUEST' });
const profileResponse = await fetch('/api/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...profileData,
userId: user.id
})
});
const profile = await profileResponse.json();
dispatch({
type: 'CREATE_PROFILE_SUCCESS',
payload: profile
});
return { user, profile };
} catch (error) {
dispatch({
type: 'CREATE_USER_FAILURE',
payload: error.message
});
throw error;
}
};
};
// Parallel API calls
const fetchDashboardData = () => {
return async (dispatch) => {
dispatch({ type: 'FETCH_DASHBOARD_REQUEST' });
try {
// Fetch multiple resources in parallel
const [users, posts, comments] = await Promise.all([
fetch('/api/users').then(res => res.json()),
fetch('/api/posts').then(res => res.json()),
fetch('/api/comments').then(res => res.json())
]);
dispatch({
type: 'FETCH_DASHBOARD_SUCCESS',
payload: { users, posts, comments }
});
} catch (error) {
dispatch({
type: 'FETCH_DASHBOARD_FAILURE',
payload: error.message
});
}
};
};
3. Polling with Thunks
// Poll for updates
const pollNotifications = (interval = 30000) => {
return (dispatch, getState) => {
const checkForUpdates = async () => {
const { auth: { user } } = getState();
if (!user) return;
try {
const response = await fetch('/api/notifications', {
headers: {
'Authorization': `Bearer ${user.token}`
}
});
const notifications = await response.json();
dispatch({
type: 'UPDATE_NOTIFICATIONS',
payload: notifications
});
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
};
// Initial check
checkForUpdates();
// Set up polling
const intervalId = setInterval(checkForUpdates, interval);
// Return cleanup function
return () => clearInterval(intervalId);
};
};
// Usage in component
useEffect(() => {
const stopPolling = dispatch(pollNotifications(30000));
return () => {
stopPolling();
};
}, [dispatch]);
Error Handling Patterns
// Comprehensive error handling
const loginUser = (credentials) => {
return async (dispatch) => {
dispatch({ type: 'LOGIN_REQUEST' });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
if (!response.ok) {
// Handle HTTP errors
const errorData = await response.json();
throw new Error(errorData.message || 'Login failed');
}
const data = await response.json();
// Save token to localStorage
localStorage.setItem('token', data.token);
dispatch({
type: 'LOGIN_SUCCESS',
payload: data.user
});
// Fetch additional user data
dispatch(fetchUserProfile());
} catch (error) {
// Differentiate error types
let errorMessage = 'An unexpected error occurred';
if (error.name === 'TypeError') {
errorMessage = 'Network error - please check your connection';
} else if (error.message) {
errorMessage = error.message;
}
dispatch({
type: 'LOGIN_FAILURE',
payload: errorMessage
});
// Optional: report to error tracking service
errorTrackingService.captureException(error);
}
};
};
// Retry mechanism
const fetchWithRetry = (url, options = {}, maxRetries = 3) => {
return async (dispatch) => {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
dispatch({
type: 'FETCH_ATTEMPT',
payload: { attempt, maxRetries }
});
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
dispatch({
type: 'FETCH_SUCCESS',
payload: data
});
return data;
} catch (error) {
lastError = error;
if (attempt === maxRetries) {
dispatch({
type: 'FETCH_FAILURE',
payload: error.message
});
throw error;
}
// Wait before retrying (exponential backoff)
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}
throw lastError;
};
};
Thunk with Extra Arguments
// Configure thunk with extra arguments
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import api from './api';
import analytics from './analytics';
const store = createStore(
rootReducer,
applyMiddleware(
thunk.withExtraArgument({ api, analytics })
)
);
// Action creator using extra arguments
const fetchUser = (userId) => {
return async (dispatch, getState, { api, analytics }) => {
dispatch({ type: 'FETCH_USER_REQUEST' });
try {
// Use injected API service
const user = await api.users.getById(userId);
dispatch({
type: 'FETCH_USER_SUCCESS',
payload: user
});
// Track with analytics service
analytics.track('user_fetched', { userId });
} catch (error) {
dispatch({
type: 'FETCH_USER_FAILURE',
payload: error.message
});
analytics.track('user_fetch_error', {
userId,
error: error.message
});
}
};
};
// Multiple extra arguments pattern
const extraArgs = {
api: apiService,
analytics: analyticsService,
config: appConfig,
logger: loggingService
};
const store = createStore(
rootReducer,
applyMiddleware(thunk.withExtraArgument(extraArgs))
);
Real-World Example: Shopping Cart
// Shopping cart thunks
const addToCart = (product, quantity = 1) => {
return async (dispatch, getState) => {
const { cart } = getState();
const existingItem = cart.items.find(item => item.id === product.id);
dispatch({
type: 'ADD_TO_CART',
payload: { product, quantity }
});
// Save to server
try {
await fetch('/api/cart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getState().auth.token}`
},
body: JSON.stringify({
productId: product.id,
quantity: existingItem ? existingItem.quantity + quantity : quantity
})
});
// Show success notification
dispatch({
type: 'SHOW_NOTIFICATION',
payload: {
type: 'success',
message: `${product.name} added to cart`
}
});
} catch (error) {
// Rollback on error
dispatch({
type: 'REMOVE_FROM_CART',
payload: { productId: product.id, quantity }
});
dispatch({
type: 'SHOW_NOTIFICATION',
payload: {
type: 'error',
message: 'Failed to update cart'
}
});
}
};
};
const checkout = (paymentDetails) => {
return async (dispatch, getState) => {
const { cart, auth } = getState();
if (cart.items.length === 0) {
dispatch({
type: 'SHOW_NOTIFICATION',
payload: {
type: 'error',
message: 'Your cart is empty'
}
});
return;
}
dispatch({ type: 'CHECKOUT_REQUEST' });
try {
// Create order
const orderResponse = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${auth.token}`
},
body: JSON.stringify({
items: cart.items,
total: cart.total,
shippingAddress: auth.user.address
})
});
const order = await orderResponse.json();
// Process payment
dispatch({ type: 'PAYMENT_REQUEST' });
const paymentResponse = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${auth.token}`
},
body: JSON.stringify({
orderId: order.id,
...paymentDetails
})
});
const payment = await paymentResponse.json();
dispatch({
type: 'CHECKOUT_SUCCESS',
payload: { order, payment }
});
// Clear cart
dispatch({ type: 'CLEAR_CART' });
// Redirect to confirmation page
dispatch(push(`/orders/${order.id}/confirmation`));
} catch (error) {
dispatch({
type: 'CHECKOUT_FAILURE',
payload: error.message
});
dispatch({
type: 'SHOW_NOTIFICATION',
payload: {
type: 'error',
message: 'Checkout failed. Please try again.'
}
});
}
};
};
// Component using these thunks
const ProductCard = ({ product }) => {
const dispatch = useDispatch();
const [quantity, setQuantity] = useState(1);
const handleAddToCart = () => {
dispatch(addToCart(product, quantity));
};
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<div className="quantity-selector">
<button onClick={() => setQuantity(q => Math.max(1, q - 1))}>
-
</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(q => q + 1)}>
+
</button>
</div>
<button onClick={handleAddToCart}>
Add to Cart
</button>
</div>
);
};
Testing Thunks
// Testing async thunks
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('user thunks', () => {
afterEach(() => {
fetchMock.restore();
});
it('creates FETCH_USER_SUCCESS when fetching user succeeds', async () => {
const user = { id: 1, name: 'John Doe' };
fetchMock.getOnce('/api/users/1', {
body: user,
headers: { 'content-type': 'application/json' }
});
const expectedActions = [
{ type: 'FETCH_USER_REQUEST' },
{ type: 'FETCH_USER_SUCCESS', payload: user }
];
const store = mockStore({ users: {} });
await store.dispatch(fetchUserById(1));
expect(store.getActions()).toEqual(expectedActions);
});
it('creates FETCH_USER_FAILURE when fetching user fails', async () => {
fetchMock.getOnce('/api/users/1', 404);
const expectedActions = [
{ type: 'FETCH_USER_REQUEST' },
{ type: 'FETCH_USER_FAILURE', payload: 'Not Found' }
];
const store = mockStore({ users: {} });
await store.dispatch(fetchUserById(1));
expect(store.getActions()).toEqual(expectedActions);
});
});
// Testing thunks with getState
describe('conditional thunks', () => {
it('fetches posts only if needed', async () => {
const storeWithPosts = mockStore({
posts: { items: [{ id: 1 }], loading: false }
});
await storeWithPosts.dispatch(fetchPostsIfNeeded());
expect(storeWithPosts.getActions()).toEqual([]);
const storeWithoutPosts = mockStore({
posts: { items: [], loading: false }
});
fetchMock.getOnce('/api/posts', {
body: [{ id: 1 }],
headers: { 'content-type': 'application/json' }
});
await storeWithoutPosts.dispatch(fetchPostsIfNeeded());
expect(storeWithoutPosts.getActions().length).toBeGreaterThan(0);
});
});
Common Pitfalls and Solutions
Pitfall 1: Not Returning the Promise
// ❌ Bad: Doesn't return the promise
const fetchData = () => async (dispatch) => {
dispatch({ type: 'FETCH_REQUEST' });
const data = await api.getData();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
};
// ✅ Good: Returns the promise for chaining
const fetchData = () => async (dispatch) => {
dispatch({ type: 'FETCH_REQUEST' });
const data = await api.getData();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
return data; // Return for chaining
};
// Usage
dispatch(fetchData()).then(data => {
// Do something after fetch completes
});
Pitfall 2: Not Handling All Error Cases
// ❌ Bad: Incomplete error handling
const fetchUser = (id) => async (dispatch) => {
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: 'Error occurred' });
}
};
// ✅ Good: Comprehensive error handling
const fetchUser = (id) => async (dispatch) => {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const user = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: user });
} catch (error) {
// Check for specific error types
if (error.name === 'TypeError') {
dispatch({ type: 'NETWORK_ERROR', payload: 'Network error' });
} else if (error.message.includes('HTTP error')) {
dispatch({ type: 'HTTP_ERROR', payload: error.message });
} else {
dispatch({ type: 'UNKNOWN_ERROR', payload: error.message });
}
}
};
Pitfall 3: Circular Dependencies
// ❌ Bad: Circular dependency
// actions.js
import store from './store';
const myAction = () => async (dispatch) => {
const state = store.getState(); // Direct store import!
// ...
};
// ✅ Good: Use getState parameter
const myAction = () => async (dispatch, getState) => {
const state = getState(); // Use provided getState
// ...
};
Practice Exercise
Task: Create a Data Synchronization System
Build a thunk-based system for synchronizing local and remote data:
- Detect when data needs syncing
- Handle offline/online status
- Queue actions when offline
- Sync when connection restored
- Handle conflicts
// TODO: Create sync system
const syncData = () => {
return async (dispatch, getState) => {
// TODO: Check online status
// TODO: Get pending changes from state
// TODO: Send changes to server
// TODO: Handle conflicts
// TODO: Update local state with server response
};
};
// TODO: Create offline queue
const queueAction = (action) => {
return (dispatch, getState) => {
// TODO: Check if online
// TODO: If offline, queue action
// TODO: If online, dispatch immediately
};
};
// TODO: Create sync manager
const syncManager = {
start: () => {
// TODO: Set up sync interval
// TODO: Listen for online/offline events
},
stop: () => {
// TODO: Clear intervals and listeners
}
};
// Example usage:
// dispatch(queueAction(updateProfile(userData)));
// syncManager.start();