β³ 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
getStateand parameters inside a thunk to make decisions - Handle errors, chaining, and parallel requests correctly
- Explain that Redux Toolkit bundles thunk by default and offers
createAsyncThunkas 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?
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
returnthe promise from your thunk so callers canawaitor chain it- Check
response.okβfetchdoes not reject on 4xx/5xx - Read state via the provided
getState, never by importing the store - Prefer
createAsyncThunkin new projects to cut boilerplate
β Don't
- Don't put
fetchor 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 getStateenables conditional and authenticated fetches;withExtraArgumentinjects services- Redux Toolkit includes thunk by default, and
createAsyncThunkis the modern, low-boilerplate async pattern
π Additional Resources
- Redux β Writing Logic with Thunks
- Redux Fundamentals β Async Logic & Data Fetching
- Redux Toolkit β createAsyncThunk API
- Redux Style Guide β Use Thunks for Async Logic
π 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.