Redux Thunk: When Async Logic Belongs in Action Creators
Redux Thunk lets action creators return functions for async work while keeping reducers pure. This post shows a concrete fetch pattern with createAsyncThunk, explains where thunks fit in the unidirectional flow, and outlines when to switch to RTK Query instead.
10 Aug 2026, 03:55 UTC

The Problem: Async in a Synchronous Store
Redux insists that reducers are pure functions and the store updates synchronously. But real applications need to fetch data, call APIs, and handle timeouts. The question isn't whether to do async work—it's where that work lives without breaking Redux's guarantees.
Redux Thunk answers this by letting action creators return functions instead of plain action objects. The middleware intercepts those functions, gives them dispatch and getState, and waits for the async work to finish before any plain actions hit the reducers.
How Thunk Fits the Unidirectional Flow
Without middleware, dispatch only accepts plain objects. Thunk adds a small shim: if the argument is a function, call it with (dispatch, getState, extraArgument) and ignore the return value. If it's an object, pass it along to the next middleware or the reducers.
This preserves the single-source-of-truth principle because every state change still arrives as a plain action. The async logic runs before those actions are dispatched, not inside reducers.
Worked Example: Fetching a User Profile
Here's a typical thunk that loads a user, handles loading state, and surfaces errors—all without leaking async code into components.
// features/user/userThunks.ts
import { createAsyncThunk } from '@reduxjs/toolkit';
import { userApi } from '../../services/api';
export const fetchUserById = createAsyncThunk(
'user/fetchById',
async (userId: string, { rejectWithValue }) => {
try {
const response = await userApi.getUser(userId);
return response.data;
} catch (err: any) {
return rejectWithValue(err.response?.data?.message ?? 'Failed to load user');
}
}
);The generated actions—user/fetchById/pending, fulfilled, rejected—are plain objects. Reducers handle them normally:
// features/user/userSlice.ts
import { createSlice } from '@reduxjs/toolkit';
import { fetchUserById } from './userThunks';
const userSlice = createSlice({
name: 'user',
initialState: { data: null, status: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.data = action.payload;
state.status = 'succeeded';
})
.addCase(fetchUserById.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload as string;
});
},
});Components dispatch the thunk like any action:
dispatch(fetchUserById('123'));Trade-offs and When to Look Elsewhere
Thunks are simple, but they have costs:
- Indirect control flow makes time-travel debugging harder. DevTools shows the pending/fulfilled/rejected actions, but the async logic itself isn't replayable.
- Error handling is manual. Forgetting a
rejectedcase leaves the store in a stale loading state. - Nesting thunks (dispatching a thunk from inside another) creates implicit dependencies that are tough to test.
If your async logic involves caching, deduplication, optimistic updates, or server-state synchronization, RTK Query or React Query handle those concerns declaratively. Thunks remain the right choice for one-off commands, complex orchestration, or when you need full control over the dispatch sequence.
Verification Checklist
- Open Redux DevTools, dispatch the thunk, and confirm the three lifecycle actions appear in order with correct payloads.
- Write a unit test that mocks
userApi.getUser, calls the thunk viastore.dispatch, and asserts the resulting actions matchpending→fulfilled(orrejected). - Add temporary
console.logstatements inside the thunk to verify execution order during development; remove before merge.
If all three pass, the thunk integrates cleanly with the store's predictable flow.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.