β³ Asynchronous Operations in Redux
Reducers must be pure and synchronous β yet real apps constantly fetch, save, and wait on servers. This lesson resolves that tension: you'll learn where async logic actually belongs, how thunks and createAsyncThunk track loading and errors, and how RTK Query removes most of the boilerplate entirely.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain why reducers can't be async and where side effects belong instead
- Understand what middleware is and how Redux Thunk enables async actions
- Model any request with the pending / fulfilled / rejected state pattern
- Write data-fetching logic with
createAsyncThunkand handle it inextraReducers - Recognize when RTK Query is the better tool for server state
Estimated Time: 40β50 minutes β’ Difficulty: Intermediate
Hands-on: Build a "fetch users" feature with full loading and error handling.
In This Lesson
Why Reducers Can't Be Async
Redux's whole promise β predictability β rests on reducers being pure functions: same inputs, same output, no side effects. A network request is the opposite of pure. It takes an unknown amount of time, might fail, and returns different data every call. If you tried to fetch inside a reducer, you'd break determinism and time-travel debugging, and the reducer would return before the data ever arrived.
π‘ An analogy: A reducer is a vending machine β insert a coin (action), get a snack (new state) instantly and identically every time. A network request is like ordering delivery: you place the order, wait, and it might arrive late or not at all. You don't turn the vending machine into a delivery service; you put the ordering logic somewhere else and feed the result back into the machine.
So async work lives outside reducers. When it finishes, it dispatches plain, synchronous actions carrying the results β and those the reducers handle normally. The tool that lets an action creator do asynchronous work before dispatching is middleware.
Middleware & Redux Thunk
Middleware sits between the moment you dispatch something and the moment it reaches the reducer. It can inspect, delay, transform, or intercept dispatched values β the perfect hook for side effects like logging or API calls.
Redux Thunk is the standard middleware for async logic β and it's included and enabled by default in Redux Toolkit's configureStore. Normally you can only dispatch plain object actions. Thunk teaches the store one new trick: you may also dispatch a function. When you do, Thunk calls that function with dispatch and getState, letting it run async code and dispatch real actions whenever it likes.
// A "thunk" is just an action creator that returns a function
function fetchUsers() {
return async (dispatch, getState) => {
dispatch({ type: 'users/loading' });
try {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const users = await res.json();
dispatch({ type: 'users/loaded', payload: users });
} catch (err) {
dispatch({ type: 'users/failed', payload: err.message });
}
};
}
// Dispatched the same way as any action
dispatch(fetchUsers());
π Key Terms
Middleware: code that runs on every dispatched action before it reaches the reducer.
Thunk: an action creator that returns a function (instead of an object) so it can run async logic and dispatch later.
Side effect: anything that reaches outside the function β network calls, timers, randomness, logging.
The Request Lifecycle
Notice the pattern in the thunk above: it dispatches three kinds of action. This is universal β every async request moves through the same three states, and modelling all three is what gives users good loading spinners and error messages.
status field plus an error.A typical slice of state for a fetched resource therefore looks like this:
const initialState = {
items: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null
};
createAsyncThunk
Writing those three actions by hand for every request gets repetitive. Redux Toolkit's createAsyncThunk does it for you. You give it a base action type and an async "payload creator" function; it returns a thunk that automatically dispatches pending, then either fulfilled (with the returned value) or rejected (with the error).
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUsers = createAsyncThunk(
'users/fetchUsers', // base type -> users/fetchUsers/pending, /fulfilled, /rejected
async (_arg, thunkAPI) => {
const res = await fetch('/api/users');
if (!res.ok) {
// Return a rejected value with a clean message
return thunkAPI.rejectWithValue(`Request failed: ${res.status}`);
}
return res.json(); // becomes action.payload of the fulfilled action
}
);
The payload creator receives two things: the argument you pass when dispatching (e.g. a user id), and the thunkAPI object (with dispatch, getState, rejectWithValue, signal, and more). Passing an argument is simple:
export const fetchUserById = createAsyncThunk(
'users/fetchUserById',
async (userId) => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
}
);
// dispatch(fetchUserById('u123'));
π‘ Why rejectWithValue?
By default a thrown error becomes action.error (a serialized Error). Use rejectWithValue(payload) when you want to send your own error shape β an API's validation messages, say β to the reducer via action.payload instead.
Handling It in a Slice
Because createAsyncThunk's actions are generated outside the slice, you respond to them in the slice's extraReducers field (not the normal reducers field, which is for actions the slice defines itself). The builder callback lets you add a case per lifecycle action:
import { createSlice } from '@reduxjs/toolkit';
import { fetchUsers } from './usersThunks';
const usersSlice = createSlice({
name: 'users',
initialState: { items: [], status: 'idle', error: null },
reducers: {
// synchronous, slice-owned actions would go here
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload; // the data returned by the thunk
})
.addCase(fetchUsers.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload ?? action.error.message;
});
}
});
export default usersSlice.reducer;
Using it in a component
import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUsers } from './usersThunks';
export function UsersList() {
const dispatch = useDispatch();
const { items, status, error } = useSelector((state) => state.users);
useEffect(() => {
if (status === 'idle') dispatch(fetchUsers());
}, [status, dispatch]);
if (status === 'loading') return <p>Loadingβ¦</p>;
if (status === 'failed') return <p role="alert">Error: {error}</p>;
return (
<ul>
{items.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
What the user sees
1. "Loadingβ¦" (fetchUsers/pending dispatched)
2. Ada Lovelace (fetchUsers/fulfilled β list renders)
Alan Turing
Grace Hopper
β¦orβ¦
"Error: Request failed: 500" (fetchUsers/rejected)
When to Reach for RTK Query
The thunk pattern is essential to understand, but for the common case β "fetch data from an endpoint and cache it" β you'll write the same loading/error/caching code over and over. RTK Query, built into Redux Toolkit, generates all of it from a declarative endpoint definition.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api/' }),
endpoints: (builder) => ({
getUsers: builder.query({ query: () => 'users' }),
getUserById: builder.query({ query: (id) => `users/${id}` }),
addUser: builder.mutation({
query: (newUser) => ({ url: 'users', method: 'POST', body: newUser })
})
})
});
// RTK Query generates hooks automatically
export const { useGetUsersQuery, useGetUserByIdQuery, useAddUserMutation } = api;
function UsersList() {
const { data: users = [], isLoading, isError, error } = useGetUsersQuery();
if (isLoading) return <p>Loadingβ¦</p>;
if (isError) return <p role="alert">Error: {String(error.status)}</p>;
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
β Rule of thumb
Use RTK Query for server state (fetching, caching, refetching, invalidation). Use createAsyncThunk + slices when async work feeds into client state you also manage synchronously, or for one-off side effects that don't fit the request/cache model.
Best Practices
| β Do | β Don't |
|---|---|
Keep fetch/axios calls in thunks or RTK Query | Call APIs inside a reducer |
Track a status field and render loading/error states | Assume requests always succeed instantly |
| Handle all three of pending/fulfilled/rejected | Only handle the happy path |
Guard duplicate fetches (check status === 'idle', or use RTK Query caching) | Re-fetch on every render |
Use rejectWithValue for custom error payloads | Swallow errors silently |
β οΈ Don't fetch in the reducer, and don't fetch on every render
Two of the most common beginner bugs: putting the request inside the reducer (breaks purity) and dispatching a fetch in a component body without an effect or guard (causes an infinite request loop). Fetch in a thunk, trigger it from useEffect, and guard against re-fetching.
Hands-on Exercise
ποΈ Build a "Fetch Posts" Feature
Objective: Create a slice that loads posts from an API with full loading and error handling.
Instructions:
- Create a
fetchPoststhunk withcreateAsyncThunkhittinghttps://jsonplaceholder.typicode.com/posts. - Create a
postsSlicewith state{ items: [], status: 'idle', error: null }. - Handle
pending,fulfilled, andrejectedinextraReducers. - In a
PostsListcomponent, dispatch the thunk fromuseEffectonly whenstatus === 'idle', and render loading, error, and success states. - Bonus: add a "Retry" button that re-dispatches the thunk after a failure.
π‘ Hint
The thunk's return value becomes action.payload in the fulfilled case. For the retry button, you don't need to reset status to 'idle' β just call dispatch(fetchPosts()) again; the pending case will set status back to 'loading'.
β Sample solution
// features/posts/postsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchPosts = createAsyncThunk(
'posts/fetchPosts',
async (_arg, { rejectWithValue }) => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!res.ok) return rejectWithValue(`HTTP ${res.status}`);
return res.json();
}
);
const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], status: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (s) => { s.status = 'loading'; s.error = null; })
.addCase(fetchPosts.fulfilled, (s, a) => { s.status = 'succeeded'; s.items = a.payload; })
.addCase(fetchPosts.rejected, (s, a) => { s.status = 'failed'; s.error = a.payload ?? a.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((s) => s.posts);
useEffect(() => {
if (status === 'idle') dispatch(fetchPosts());
}, [status, dispatch]);
if (status === 'loading') return <p>Loadingβ¦</p>;
if (status === 'failed')
return (
<div role="alert">
<p>Error: {error}</p>
<button onClick={() => dispatch(fetchPosts())}>Retry</button>
</div>
);
return <ul>{items.slice(0, 10).map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
π― Quick Quiz
Question 1: Why can't you put a fetch call directly inside a reducer?
Question 2: Which three action types does createAsyncThunk generate?
Question 3: Where do you respond to a createAsyncThunk's actions inside a slice?
Summary & Quiz
π Key Takeaways
- Reducers stay pure and synchronous; async logic lives in middleware.
- Redux Thunk (on by default in RTK) lets you dispatch functions that run async code and dispatch later.
- Every request follows the pending β fulfilled / rejected lifecycle β track it with a
statusfield and anerror. createAsyncThunkgenerates those three actions; handle them inextraReducers.- RTK Query is the higher-level tool for fetching and caching server data with almost no boilerplate.
π Further Reading
- Redux Essentials β Async Logic & Data Fetching
- createAsyncThunk API Reference
- RTK Query Overview
- Writing Logic with Thunks
π What's Next?
You've now covered Redux end to end β concepts, Toolkit, and async. Next the module pivots to a different framework: you'll explore Vue.js framework architecture and see how another ecosystem solves the same UI and state problems.
π Redux mastered!
Sync, structure, and async β you can now manage real application state with confidence.