Redux Thunk for Async Actions

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();
                

Additional Resources