Skip to main content

⏳ Redux Thunk for Async Actions

Reducers must be pure and dispatch is synchronous β€” so where does the fetch go? Redux Thunk answers that with one tiny, elegant middleware: it lets an action creator return a function instead of a plain object. That function receives dispatch and getState, so it can do async work and fire actions whenever the data is ready.

Week 6 · Day 3 (Wednesday: Redux Middleware) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a "thunk" is and why async logic needs middleware
  • Read the ~10-line thunk middleware and describe exactly what it checks
  • Write thunk action creators that dispatch request / success / failure actions
  • Use getState and parameters inside a thunk to make decisions
  • Handle errors, chaining, and parallel requests correctly
  • Explain that Redux Toolkit bundles thunk by default and offers createAsyncThunk as the modern pattern

Estimated Time: 65 minutes

Practice: Build a fetchUsers thunk with full loading and error handling.

In This Lesson

Why Thunk Exists

Plain Redux only accepts plain object actions, and it processes them synchronously. That is a deliberate design choice β€” it keeps state changes predictable and time-travel debugging possible. But real apps need to load data from servers, and a network request takes time. You cannot put a fetch inside a reducer (reducers must be pure), and a single dispatch cannot "wait" for a response.

The word thunk is old programming jargon for "a piece of code that delays some work" β€” a function you hand off now to be run later. Redux Thunk is a ~14-line middleware that teaches the store one new trick: if an action is a function, call it instead of forwarding it to the reducer. That function is your async escape hatch.

🎭 The Theater Director Analogy

A plain action is a single line of stage direction β€” "the lights come up" β€” executed instantly. A thunk is a director: you hand it the stage (dispatch) and a view of the whole set (getState), and it coordinates several scenes over time β€” "start the loading spinner… wait for the actor to arrive from off-stage… now reveal the data… and if they trip, run the understudy scene." One thunk, many carefully sequenced dispatches.

How Thunk Works

The entire idea fits in one decision: is the dispatched thing a function or an object?

graph TD A["dispatch(something)"] --> B{"typeof === 'function'?"} B -->|No, plain object| C[Pass to next / reducer] B -->|Yes, a thunk| D["call it with (dispatch, getState)"] D --> E[Async work: fetch, timers] E --> F["dispatch REQUEST"] E --> G["dispatch SUCCESS or FAILURE"] F --> C G --> C

Here is the actual middleware β€” genuinely this short. It is the clearest example of the curried store => next => action signature you met in the previous lesson.

// The real redux-thunk, lightly annotated
function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    // If someone dispatched a FUNCTION, run it and hand it the tools
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }
    // Otherwise it's a normal action β€” forward it untouched
    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;

Classic setup wires it in with applyMiddleware:

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

const store = createStore(rootReducer, applyMiddleware(thunk));

βœ… You probably won't install thunk yourself

Modern Redux apps use Redux Toolkit, and configureStore includes the thunk middleware by default. The manual applyMiddleware(thunk) setup above is shown so you understand what is happening β€” but with Toolkit you can write thunks immediately with zero configuration.

Basic Thunk Patterns

1. The request / success / failure trio

The canonical async pattern dispatches three actions across the lifetime of one request: one when it starts (flip on a spinner), one on success (store the data), and one on failure (store the error). First, three tiny plain-object 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 });

Now the thunk that orchestrates them. It returns an async function, so dispatch is its first argument:

const fetchUsers = () => {
  return async (dispatch) => {
    dispatch(fetchUsersRequest());                 // 1. spinner ON
    try {
      const response = await fetch('/api/users');
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const users = await response.json();
      dispatch(fetchUsersSuccess(users));          // 2. store data, spinner OFF
    } catch (error) {
      dispatch(fetchUsersFailure(error.message));  // 3. store error, spinner OFF
    }
  };
};

From a component it feels exactly like dispatching any other action β€” thunk hides all the machinery:

import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';

function UserList() {
  const dispatch = useDispatch();
  const { users, loading, error } = useSelector(state => state.users);

  useEffect(() => {
    dispatch(fetchUsers());   // dispatch a FUNCTION β€” thunk runs it
  }, [dispatch]);

  if (loading) return <div>Loading…</div>;
  if (error)   return <div>Error: {error}</div>;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

2. Thunks with parameters and getState

Because a thunk is just a function you write, it can take arguments and read current state. This one refuses to fetch unless the user is logged in:

const fetchRelatedPosts = (postId) => {
  return async (dispatch, getState) => {
    const { user } = getState().auth;
    if (!user) {
      return dispatch({ type: 'FETCH_RELATED_FAILURE', payload: 'Must be logged in' });
    }

    dispatch({ type: 'FETCH_RELATED_REQUEST' });
    try {
      const res = await fetch(`/api/posts/${postId}/related`, {
        headers: { Authorization: `Bearer ${user.token}` }
      });
      const posts = await res.json();
      dispatch({ type: 'FETCH_RELATED_SUCCESS', payload: posts });
    } catch (error) {
      dispatch({ type: 'FETCH_RELATED_FAILURE', payload: error.message });
    }
  };
};

Advanced Patterns

1. Conditional dispatch (skip redundant fetches)

Read state and bail out early if the data is already loaded β€” a cheap, powerful optimization:

const fetchPostsIfNeeded = () => {
  return (dispatch, getState) => {
    const { posts } = getState();
    if (posts.items.length === 0 && !posts.loading) {
      return dispatch(fetchPosts());   // one thunk can dispatch another
    }
    return Promise.resolve();          // nothing to do
  };
};

2. Chained (sequential) requests

Because thunks use async/await, sequencing dependent calls is just normal JavaScript β€” create a user, then create their profile with the returned id:

const createUserAndProfile = (userData, profileData) => {
  return async (dispatch) => {
    dispatch({ type: 'CREATE_USER_REQUEST' });
    try {
      const user = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData)
      }).then(r => r.json());
      dispatch({ type: 'CREATE_USER_SUCCESS', payload: user });

      // now use the new user's id
      const profile = await fetch('/api/profiles', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...profileData, userId: user.id })
      }).then(r => r.json());
      dispatch({ type: 'CREATE_PROFILE_SUCCESS', payload: profile });

      return { user, profile };
    } catch (error) {
      dispatch({ type: 'CREATE_USER_FAILURE', payload: error.message });
      throw error;
    }
  };
};

3. Parallel requests

When calls are independent, fire them together with Promise.all so the user waits once, not three times:

const fetchDashboard = () => {
  return async (dispatch) => {
    dispatch({ type: 'FETCH_DASHBOARD_REQUEST' });
    try {
      const [users, posts, comments] = await Promise.all([
        fetch('/api/users').then(r => r.json()),
        fetch('/api/posts').then(r => r.json()),
        fetch('/api/comments').then(r => r.json())
      ]);
      dispatch({ type: 'FETCH_DASHBOARD_SUCCESS', payload: { users, posts, comments } });
    } catch (error) {
      dispatch({ type: 'FETCH_DASHBOARD_FAILURE', payload: error.message });
    }
  };
};

4. Injecting services with withExtraArgument

The third parameter of a thunk is whatever you pass to thunk.withExtraArgument (or Toolkit's extraArgument option) β€” perfect for injecting an API client so thunks stay testable:

const store = createStore(
  rootReducer,
  applyMiddleware(thunk.withExtraArgument({ api }))
);

const fetchUser = (id) => async (dispatch, getState, { api }) => {
  dispatch({ type: 'FETCH_USER_REQUEST' });
  try {
    const user = await api.users.getById(id);   // injected service, easy to mock
    dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
  } catch (error) {
    dispatch({ type: 'FETCH_USER_FAILURE', payload: error.message });
  }
};

Error Handling

Good async code distinguishes between network failures (no connection) and HTTP failures (server said 404 or 500). Note that fetch does not reject on 4xx/5xx β€” you must check response.ok yourself.

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) {                         // fetch won't throw on 401!
        const { message } = await response.json();
        throw new Error(message || 'Login failed');
      }

      const { user, token } = await response.json();
      localStorage.setItem('token', token);
      dispatch({ type: 'LOGIN_SUCCESS', payload: user });
    } catch (error) {
      const message = error.name === 'TypeError'
        ? 'Network error β€” please check your connection'   // fetch rejected = network down
        : error.message;
      dispatch({ type: 'LOGIN_FAILURE', payload: message });
    }
  };
};

⚠️ Retry with exponential backoff

For flaky endpoints, retry a few times with growing delays instead of failing on the first hiccup:

const fetchWithRetry = (url, maxRetries = 3) => async (dispatch) => {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      return dispatch({ type: 'FETCH_SUCCESS', payload: data });
    } catch (error) {
      if (attempt === maxRetries) {
        return dispatch({ type: 'FETCH_FAILURE', payload: error.message });
      }
      // wait 2s, 4s, 8s… before trying again
      await new Promise(r => setTimeout(r, 2 ** attempt * 1000));
    }
  }
};

The Modern Way: createAsyncThunk

Writing the request/success/failure trio by hand for every endpoint gets repetitive fast. Redux Toolkit β€” the officially recommended way to write Redux β€” provides createAsyncThunk, which generates those three action types for you and dispatches them automatically around your async function.

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

// One call generates users/fetch/pending, /fulfilled, and /rejected
export const fetchUsers = createAsyncThunk('users/fetch', async () => {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();      // becomes action.payload on "fulfilled"
});

const usersSlice = createSlice({
  name: 'users',
  initialState: { items: [], loading: false, error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending,   (state) => { state.loading = true;  state.error = null; })
      .addCase(fetchUsers.fulfilled, (state, action) => { state.loading = false; state.items = action.payload; })
      .addCase(fetchUsers.rejected,  (state, action) => { state.loading = false; state.error = action.error.message; });
  }
});

πŸ’‘ Same idea, less boilerplate

Under the hood createAsyncThunk is a thunk β€” it relies on the very middleware you just learned. Everything in this lesson still applies; Toolkit simply automates the loading/success/error dispatches. Understanding the manual version first is what makes the shortcut make sense.

Practice & Quiz

πŸ‹οΈ Exercise 1: A products thunk

Goal: Write fetchProducts, a thunk that dispatches PRODUCTS_REQUEST, then PRODUCTS_SUCCESS with the JSON on success, or PRODUCTS_FAILURE with the message on error. Guard against non-OK HTTP responses.

const fetchProducts = () => {
    // TODO: return async (dispatch) => { ... }
};
πŸ’‘ Hint

Return async (dispatch) => {}. Dispatch the request action first, await fetch('/api/products'), throw if !response.ok, then dispatch success with the parsed body inside a try and failure inside the catch.

βœ… Solution
const fetchProducts = () => {
    return async (dispatch) => {
        dispatch({ type: 'PRODUCTS_REQUEST' });
        try {
            const res = await fetch('/api/products');
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            const products = await res.json();
            dispatch({ type: 'PRODUCTS_SUCCESS', payload: products });
        } catch (error) {
            dispatch({ type: 'PRODUCTS_FAILURE', payload: error.message });
        }
    };
};

πŸ‹οΈ Exercise 2: Only fetch once

Goal: Write fetchProductsIfNeeded that dispatches fetchProducts() only when state.products.items is empty and it is not already loading.

βœ… Solution
const fetchProductsIfNeeded = () => {
    return (dispatch, getState) => {
        const { products } = getState();
        if (products.items.length === 0 && !products.loading) {
            return dispatch(fetchProducts());
        }
        return Promise.resolve();
    };
};

🎯 Quick Quiz

Question 1: What lets a thunk action creator do async work?

Question 2: Which check does the thunk middleware make on each action?

Question 3: Which statement about Redux Toolkit is correct?

Best Practices & Pitfalls

βœ… Do

  • Dispatch a request / success / failure trio so the UI can show loading and error states
  • return the promise from your thunk so callers can await or chain it
  • Check response.ok β€” fetch does not reject on 4xx/5xx
  • Read state via the provided getState, never by importing the store
  • Prefer createAsyncThunk in new projects to cut boilerplate

❌ Don't

  • Don't put fetch or timers inside reducers β€” they must stay pure
  • Don't import the store directly inside an action file (circular dependency); use getState
  • Don't swallow errors with a generic message β€” surface what actually failed
  • Don't forget to dispatch a failure action, or the spinner spins forever

⚠️ Return the promise

// ❌ Caller can't tell when this finished
const fetchData = () => async (dispatch) => {
  const data = await api.getData();
  dispatch({ type: 'FETCH_SUCCESS', payload: data });
};

// βœ… Return it so dispatch(fetchData()).then(...) works
const fetchData = () => async (dispatch) => {
  const data = await api.getData();
  dispatch({ type: 'FETCH_SUCCESS', payload: data });
  return data;
};

Summary

πŸŽ‰ Key Takeaways

  • A thunk is an action creator that returns a function, letting you run async logic
  • The thunk middleware simply checks typeof action === 'function' and, if so, calls it with (dispatch, getState, extra)
  • The workhorse pattern is dispatching request / success / failure around an awaited request
  • getState enables conditional and authenticated fetches; withExtraArgument injects services
  • Redux Toolkit includes thunk by default, and createAsyncThunk is the modern, low-boilerplate async pattern

πŸ“š Additional Resources

πŸš€ What's Next?

You have used a ready-made middleware; now you will build your own. The next lesson, Custom Middleware, shows how to write validation, persistence, rate-limiting, and analytics middleware from scratch β€” applying the exact store => next => action pattern thunk is built on.

πŸŽ‰ Async unlocked!

You can now load real data into Redux the right way β€” with loading states, error handling, and a clear path to the modern Toolkit approach.