π Asynchronous Actions with createAsyncThunk
Redux is synchronous by design β dispatch an action, the reducer runs, state updates. But real apps fetch data, save forms, and log in, all of which take time. createAsyncThunk is Redux Toolkit's standard, low-boilerplate way to fold that asynchronous work into your store.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain why Redux needs middleware for async work and where
createAsyncThunkfits - Trace the automatic pending β fulfilled β rejected action lifecycle
- Use the thunkAPI object β
dispatch,getState,signal, andrejectWithValue - Model loading states and structured errors in
extraReducers - Apply advanced patterns β cancellation, conditional execution, dependent requests, and optimistic updates
- Write tests that verify the right actions are dispatched
Estimated Time: 40β50 minutes β’ Difficulty: IntermediateβAdvanced
Hands-on: Build a fetchPosts thunk with full loading/error handling against a real API.
In This Lesson
The Async Challenge in Redux
Reducers must be pure and synchronous. So where does asynchronous work β API calls, local storage, WebSockets, auth flows β actually live? Without middleware, Redux has no answer, and the logic leaks into your components, making them messy and hard to test.
Historically, teams solved this with one of three middleware libraries:
- Redux Thunk β functions that can
dispatchand read state. Simplest, most common. - Redux Saga β generator functions for complex, long-running flows.
- Redux Observable β RxJS streams for reactive patterns.
All work, but the thunk approach in particular required a lot of hand-written ceremony β three action types, three action creators, and a switch reducer for every single request:
// β The old hand-written thunk pattern β repeated for EVERY request
const FETCH_POSTS_REQUEST = 'posts/fetchPostsRequest';
const FETCH_POSTS_SUCCESS = 'posts/fetchPostsSuccess';
const FETCH_POSTS_FAILURE = 'posts/fetchPostsFailure';
const fetchPosts = () => async (dispatch) => {
dispatch({ type: FETCH_POSTS_REQUEST });
try {
const response = await fetch('https://api.example.com/posts');
if (!response.ok) throw new Error('Failed to fetch posts');
const data = await response.json();
dispatch({ type: FETCH_POSTS_SUCCESS, payload: data });
} catch (error) {
dispatch({ type: FETCH_POSTS_FAILURE, error: error.message });
}
};
// ...plus a switch reducer handling all three types.
createAsyncThunk collapses that entire pattern into a single call β and it's still Redux Thunk underneath, just generated for you.
Introducing createAsyncThunk
createAsyncThunk takes a type prefix and an async "payload creator." It then automatically creates three action types β pending, fulfilled, and rejected β and dispatches them around your promise. You handle them in a slice's extraReducers:
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchPosts = createAsyncThunk(
'posts/fetchPosts', // type prefix
async () => {
const response = await fetch('https://api.example.com/posts');
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json(); // becomes action.payload on success
}
);
// Auto-generated: posts/fetchPosts/pending | /fulfilled | /rejected
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], loading: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.loading = 'loading';
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.loading = 'idle';
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.loading = 'idle';
state.error = action.error.message;
});
},
});
export default postsSlice.reducer;
Here's the full round trip when a component dispatches the thunk:
π‘ The package-delivery analogy. Dispatching the thunk is like booking a courier pickup. The service immediately confirms the booking (pending), handles all the logistics behind the scenes (the async work), and notifies you when the parcel is delivered (fulfilled) or when something went wrong (rejected) β all tracked in one standard way, no matter what's in the box.
The API & thunkAPI
The signature is three parts: a type prefix, a payload creator, and optional options.
const asyncThunk = createAsyncThunk(typePrefix, payloadCreator, options);
- typePrefix (string) β the base for the generated types, usually
'domain/action'. - payloadCreator (async function) β receives
(arg, thunkAPI)and returns a promise; the resolved value becomesaction.payload. - options (object) β extra config such as a
conditionto skip execution.
The second argument, thunkAPI, is a toolbox of everything the thunk might need:
export const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId, thunkAPI) => {
const {
dispatch, // the store's dispatch β fire more actions
getState, // read the current store state
extra, // the "extra argument" given to the thunk middleware
requestId, // unique id for this thunk run
signal, // AbortSignal for cancellation
rejectWithValue, // return a custom rejected payload
fulfillWithValue, // return a custom fulfilled payload
} = thunkAPI;
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
return rejectWithValue({ status: response.status, message: 'Failed to fetch user' });
}
return response.json();
}
);
The returned action creator carries .pending, .fulfilled, and .rejected properties (the action creators for each phase), plus a .typePrefix string. Calling it returns a promise you can .unwrap() to get the raw value or throw.
Structured Errors with rejectWithValue
If your payload creator throws, the rejected action's action.error holds a serialized version of the error. That's fine for simple cases, but often you want richer, structured error info β an HTTP status, an API error body, a timestamp. That's what rejectWithValue is for: whatever you pass it lands on action.payload of the rejected action.
export const loginUser = createAsyncThunk(
'auth/login',
async (credentials, { rejectWithValue }) => {
try {
const response = await fetch('https://api.example.com/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
const data = await response.json();
if (!response.ok) {
// Structured error payload β action.payload in the reducer
return rejectWithValue({ status: response.status, data, timestamp: Date.now() });
}
return data;
} catch (error) {
return rejectWithValue({ status: 'NETWORK_ERROR', message: error.message, timestamp: Date.now() });
}
}
);
// In the slice, distinguish rejectWithValue payloads from thrown errors:
.addCase(loginUser.rejected, (state, action) => {
state.loading = 'idle';
if (action.payload) {
// Came from rejectWithValue β structured
const { status, data } = action.payload;
state.error = status === 401 ? 'Invalid credentials' : data?.message || 'Login failed';
} else {
// Came from a thrown error β use the serialized error
state.error = action.error.message;
}
});
β οΈ payload vs. error
On a rejected action, check action.payload first β it's only present when you used rejectWithValue. If it's undefined, the thunk threw, so fall back to action.error.message. Getting this backwards is a classic source of "undefined" error messages in the UI.
Loading States in Reducers
The most common way to track a request is a string status field. A four-state machine covers virtually every UI need:
| Status | Meaning | Typical UI |
|---|---|---|
'idle' | No request made yet | Nothing / initial view |
'loading' | Request in flight | Spinner |
'succeeded' | Request completed | The data |
'failed' | Request errored | Error message + retry |
const usersSlice = createSlice({
name: 'users',
initialState: { entities: [], loading: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = 'loading';
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = 'succeeded';
state.entities = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = 'failed';
state.error = action.payload?.message ?? action.error.message;
});
},
});
Components then render straight off the status β and can avoid re-fetching data they already have:
function UsersList() {
const dispatch = useDispatch();
const { entities, loading, error } = useSelector((state) => state.users);
useEffect(() => {
if (loading === 'idle') dispatch(fetchUsers()); // fetch once
}, [loading, dispatch]);
if (loading === 'loading') return <p>Loading usersβ¦</p>;
if (loading === 'failed') return <p>Error: {error}</p>;
return (
<ul>
{entities.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
π‘ Reading the original argument
Inside any lifecycle reducer, action.meta.arg holds the argument you dispatched the thunk with. That's how you'd track status per id β e.g. state.status[action.meta.arg] = 'loading' β when many requests of the same type run in parallel.
Advanced Techniques
Cancellation with AbortSignal
Every thunk receives a signal you can pass to fetch. Dispatching returns a promise with an .abort() method β call it on unmount to cancel in-flight requests:
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (_, { signal }) => {
const response = await fetch('https://api.example.com/users', { signal });
return response.json(); // throws AbortError if cancelled
}
);
useEffect(() => {
const promise = dispatch(fetchUsers());
return () => promise.abort(); // cancel if the component unmounts
}, [dispatch]);
Conditional execution
The condition option can skip a thunk entirely β perfect for avoiding duplicate fetches:
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async () => {
const response = await fetch('https://api.example.com/users');
return response.json();
},
{
condition: (_, { getState }) => {
const status = getState().users.loading;
// Returning false cancels before pending is even dispatched.
return status !== 'succeeded' && status !== 'loading';
},
}
);
Dependent requests
Chain calls by awaiting .unwrap() on a dispatched thunk, which yields the fulfilled value or throws:
export const fetchUserWithPosts = createAsyncThunk(
'users/fetchWithPosts',
async (userId, { dispatch }) => {
const user = await dispatch(fetchUserById(userId)).unwrap();
const posts = await dispatch(fetchPostsByAuthor(user.username)).unwrap();
return { user, posts };
}
);
Optimistic updates
For instant-feeling UIs, update the store before the request resolves, then roll back if it fails:
export const toggleTodoStatus = createAsyncThunk(
'todos/toggleStatus',
async (todoId, { dispatch, getState, rejectWithValue }) => {
const todo = getState().todos.entities.find((t) => t.id === todoId);
if (!todo) return rejectWithValue('Todo not found');
const previous = todo.completed;
// Optimistically flip it now
dispatch(todosSlice.actions.updateTodo({ id: todoId, changes: { completed: !previous } }));
try {
const response = await fetch(`https://api.example.com/todos/${todoId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !previous }),
});
if (!response.ok) throw new Error('Update failed');
return response.json();
} catch (error) {
// Roll back on failure
dispatch(todosSlice.actions.updateTodo({ id: todoId, changes: { completed: previous } }));
return rejectWithValue(error.message);
}
}
);
β Orchestration is a strength
Because a thunk has dispatch and getState, it can coordinate multi-step flows β validate, call several endpoints, dispatch notifications, track analytics, and roll back on error β all in one testable place, keeping your components focused purely on rendering.
Testing Async Thunks
There are two levels worth testing: that the reducer handles each lifecycle action, and that dispatching the thunk fires the right sequence of actions. The reducer test is the simplest and highest-value β it's just a pure function:
import reducer from './usersSlice';
import { fetchUsers } from './usersSlice';
const initialState = { entities: [], loading: 'idle', error: null };
test('handles fetchUsers.pending', () => {
const next = reducer(initialState, { type: fetchUsers.pending.type });
expect(next.loading).toBe('loading');
expect(next.error).toBeNull();
});
test('handles fetchUsers.fulfilled', () => {
const users = [{ id: 1, name: 'Ada' }];
const next = reducer(initialState, { type: fetchUsers.fulfilled.type, payload: users });
expect(next.loading).toBe('succeeded');
expect(next.entities).toEqual(users);
});
test('handles fetchUsers.rejected', () => {
const action = { type: fetchUsers.rejected.type, error: { message: 'Boom' } };
const next = reducer(initialState, action);
expect(next.loading).toBe('failed');
expect(next.error).toBe('Boom');
});
To test the thunk end-to-end, mock fetch, dispatch it against a real (or mock) store, and assert the dispatched action types:
test('fetchUsers dispatches pending then fulfilled', async () => {
const users = [{ id: 1, name: 'Ada' }];
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(users),
});
const dispatch = jest.fn();
const thunk = fetchUsers();
await thunk(dispatch, () => ({}), undefined);
const types = dispatch.mock.calls.map((call) => call[0].type);
expect(types).toContain(fetchUsers.pending.type);
expect(types).toContain(fetchUsers.fulfilled.type);
});
π Note on tooling
Older guides reach for redux-mock-store. Modern RTK guidance prefers testing against a real store built with configureStore, or testing the reducer as a pure function as shown above β it exercises the actual code paths rather than a mock's approximation of them.
Hands-on Exercise
ποΈ Build a fetchPosts Thunk
Objective: Wire up a complete data-fetching feature β thunk, lifecycle handling, and a component that shows loading, error, and success states.
Requirements:
- Create a
fetchPoststhunk that GETshttps://jsonplaceholder.typicode.com/posts. - Throw on a non-OK response so the
rejectedcase fires. - Give the slice
{ items: [], status: 'idle', error: null }and handle all three lifecycle actions. - In a component, dispatch the thunk once (only when status is
'idle') and render each state.
π‘ Hint
Guard the initial fetch with if (status === 'idle') dispatch(fetchPosts()) inside a useEffect keyed on [status, dispatch]. In the rejected reducer, prefer action.error.message since this thunk throws rather than using rejectWithValue.
β Solution
// features/posts/postsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
});
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], status: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
export default postsSlice.reducer;
// features/posts/PostsList.jsx
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchPosts } from './postsSlice';
export function PostsList() {
const dispatch = useDispatch();
const { items, status, error } = useSelector((state) => state.posts);
useEffect(() => {
if (status === 'idle') dispatch(fetchPosts());
}, [status, dispatch]);
if (status === 'loading') return <p>Loadingβ¦</p>;
if (status === 'failed') return <p>Error: {error}</p>;
return (
<ul>
{items.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
π― Quick Quiz
Question 1: A thunk with prefix 'posts/fetchPosts' generates which three action types?
Question 2: Inside a rejected reducer, where does the value from rejectWithValue(...) appear?
Question 3: You want to skip a fetch entirely when the data is already loaded. Which option do you use?
Summary & Quiz
π Key Takeaways
- Redux is synchronous; async work needs middleware, and
createAsyncThunkis RTK's low-boilerplate answer. - Each thunk auto-generates pending / fulfilled / rejected actions you handle in
extraReducers. - thunkAPI supplies
dispatch,getState,signal, andrejectWithValuefor structured errors. - Track requests with a status field (
idle/loading/succeeded/failed); read the dispatched arg viaaction.meta.arg. - Advanced patterns β cancellation,
condition, dependent.unwrap()chains, and optimistic updates β all live inside the thunk.
π Further Reading
- createAsyncThunk API Documentation
- Redux Essentials, Part 5: Async Logic
- RTK Query Overview (for cache-heavy fetching)
- createAsyncThunk with TypeScript
π What's Next?
Your state layer is now complete β synchronous slices and async thunks working together. The next module shifts from data to navigation. In React Router Fundamentals you'll learn to turn a single-page app into a multi-view experience with client-side routing.
π Async unlocked!
You can now move real data through Redux. On to routing.