React 69 ⚛️ Redux Middleware and Async Thunks
A reducer is a pure function, and a pure function cannot fetch data, log to the console, or read the current time. The actions that produce those side effects must live somewhere else, and the middleware is the mechanism that sits between the dispatch and the reducer. The middleware sees every action, it can pass the action on, it can block it, it can dispatch the new actions, or it can delay the action until the async work completes. The thunk is the standard middleware for the async logic, and the createAsyncThunk is the Redux Toolkit’s wrapper that produces the lifecycle actions from the one async function.
Key point: A thunk is a function that returns a function. The outer function receives the dispatch and the getState, and the inner function performs the async work and dispatches the result. The createAsyncThunk generates the pending, the fulfilled, and the rejected actions from the one async function, and the slice’s extraReducers handles them.
Why the middleware and the thunks exist
The purity problem. The reducer must be the pure function, and the side effect is the impure. The reducer cannot fetch the API, cannot write the localStorage, and cannot dispatch the follow-up action. The middleware is the place for the side effects.
The async problem. The action is the plain object, and the reducer runs synchronously. The API call takes the time, and the result arrives later. The thunk middleware lets the dispatch accept a function, and the function performs the async work.
The lifecycle problem. The async operation has the three states: the loading, the success, and the failure. The createAsyncThunk generates the three actions, and the slice stores the status.
The composition problem. The middleware chain composes. The thunk, the logger, the analytics, and the error reporter each see the action in the order. The order is the configuration.
The reusability problem. The custom middleware packages the cross-cutting concern. The logger, the crash reporter, and the offline queue are the examples. The middleware is the plugin.
The debugging problem. The Redux DevTools show the action log. The thunk actions are the named, and the DevTools replay them. The async flow is the visible.
The testing problem. The thunk is the function, and the test calls it with the mock dispatch and the mock getState. The async work is the mock, and the assertions are the dispatched actions.
a. The custom middleware
The middleware is a function that returns a function that returns a function. The signature is store => next => action => { ... }.
The basic middleware
const logger = (store) => (next) => (action) => {
console.log('dispatching:', action);
const result = next(action);
console.log('next state:', store.getState());
return result;
};
export default logger;
The store gives the dispatch and the getState, the next passes the action on, and the action is the dispatched. The next(action) returns the result.
The middleware registration
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import logger from './logger';
export const store = configureStore({
reducer: { todos: todosReducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});
The getDefaultMiddleware returns the default middleware (the thunk and the immutability check), and the concat adds the custom.
The action filtering
const analytics = (store) => (next) => (action) => {
if (action.type.startsWith('todos/')) {
window.analytics.track(action.type, action.payload);
}
return next(action);
};
The middleware filters the actions by the type and sends the analytics.
The blocking middleware
const blocklist = (store) => (next) => (action) => {
if (action.type === 'dangerous/action') {
console.warn('Blocked:', action.type);
return;
}
return next(action);
};
The middleware returns without calling the next, and the action does not reach the reducer.
The delaying middleware
const delay = (store) => (next) => (action) => {
if (!action.meta?.delay) return next(action);
return new Promise((resolve) => {
setTimeout(() => resolve(next(action)), action.meta.delay);
});
};
The middleware delays the action and returns the promise.
The dispatch in the middleware
const notify = (store) => (next) => (action) => {
const result = next(action);
if (action.type === 'todos/addTodo') {
store.dispatch(showNotification('Todo added'));
}
return result;
};
The middleware dispatches the follow-up action after the original.
The middleware table
| Element | Purpose |
|---|---|
store | The dispatch and the getState |
next | The pass the action on |
action | The dispatched |
next(action) | The return the result |
store.dispatch | The dispatch the new |
store.getState() | The read the state |
b. The createAsyncThunk
The createAsyncThunk takes the type prefix and the async function, and returns the thunk action creator with the .pending, the .fulfilled, and the .rejected action types.
The basic thunk
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await fetch('/api/todos');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
});
The 'todos/fetchTodos' is the prefix, and the async function is the payload creator.
The generated action types
todos/fetchTodos/pending
todos/fetchTodos/fulfilled
todos/fetchTodos/rejected
The three action types are the prefix plus the lifecycle.
The payload creator arguments
export const fetchTodo = createAsyncThunk(
'todos/fetchTodo',
async (todoId, { getState, rejectWithValue, signal }) => {
const { auth } = getState();
const response = await fetch(`/api/todos/${todoId}`, {
headers: { Authorization: `Bearer ${auth.token}` },
signal,
});
if (!response.ok) {
return rejectWithValue(await response.json());
}
return response.json();
}
);
The payload creator receives the argument and the thunk API with the getState, the dispatch, the rejectWithValue, the signal, and the extra.
The thunk API
| Property | Purpose |
|---|---|
dispatch | The dispatch the action |
getState | The read the state |
rejectWithValue | The custom rejection |
signal | The abort signal |
extra | The extra argument |
requestId | The unique request |
The dispatch of the thunk
dispatch(fetchTodos());
dispatch(fetchTodo(42));
The dispatch accepts the thunk function, and the thunk middleware runs it.
The extraReducers
import { createSlice } from '@reduxjs/toolkit';
import { fetchTodos } from './todosThunks';
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], status: 'idle', error: null },
reducers: { ... },
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload ?? action.error.message;
});
},
});
The three cases update the status and the data.
The rejectWithValue
if (!response.ok) {
return rejectWithValue(await response.json());
}
The rejectWithValue sets the action.payload in the rejected action, and the error.message is the standard.
The unwrap()
try {
const todos = await dispatch(fetchTodos()).unwrap();
console.log('Fetched:', todos);
} catch (error) {
console.error('Failed:', error);
}
The .unwrap() returns the promise that resolves to the payload or rejects with the rejectWithValue value.
The thunk conditions
export const fetchTodos = createAsyncThunk(
'todos/fetchTodos',
async () => { ... },
{
condition: (arg, { getState }) => {
const { todos } = getState();
if (todos.status === 'loading') return false;
},
}
);
The condition returns false to skip the thunk.
The thunk cancellation
const promise = dispatch(fetchTodos());
promise.abort();
The thunk returns the promise with the .abort() method.
The thunk table
| Element | Purpose |
|---|---|
| The prefix | The action type |
| The payload creator | The async function |
.pending | The loading |
.fulfilled | The success |
.rejected | The failure |
rejectWithValue | The custom error |
.unwrap() | The promise |
condition | The skip |
c. The async patterns
The patterns below are the ones that appear in the real applications.
The fetch and store
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await fetch('/api/todos');
return response.json();
});
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => { state.status = 'loading'; })
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
The classic fetch and store.
The chained thunk
export const addTodoAndFetch = (text) => async (dispatch) => {
await dispatch(addTodoAsync(text)).unwrap();
await dispatch(fetchTodos()).unwrap();
};
The thunk that dispatches the other thunks.
The optimistic thunk
export const addTodoOptimistic = (text) => async (dispatch, getState) => {
const tempId = nanoid();
dispatch(addTodoOptimisticLocal({ id: tempId, text }));
try {
const saved = await addTodoApi(text);
dispatch(replaceTodo({ tempId, todo: saved }));
} catch (error) {
dispatch(removeTodoLocal(tempId));
}
};
The thunk that dispatches the optimistic action and rolls back on error.
The error handling
extraReducers: (builder) => {
builder
.addCase(fetchTodos.rejected, (state, action) => {
if (action.payload) {
state.error = action.payload.message;
} else {
state.error = action.error.message;
}
});
},
The action.payload is the rejectWithValue value, and the action.error.message is the standard.
The listener middleware
import { createListenerMiddleware } from '@reduxjs/toolkit';
const listenerMiddleware = createListenerMiddleware();
listenerMiddleware.startListening({
actionCreator: addTodo,
effect: async (action, listenerApi) => {
await listenerApi.delay(500);
listenerApi.dispatch(fetchTodos());
},
});
export const store = configureStore({
reducer: { todos: todosReducer },
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware),
});
The createListenerMiddleware is the Redux Toolkit’s alternative to the thunk for the side effects. The startListening registers the effect.
The pattern table
| Pattern | Mechanism |
|---|---|
| The fetch and store | The createAsyncThunk |
| The chained thunk | The dispatch inside |
| The optimistic | The local action + the API |
| The error handling | The rejectWithValue |
| The listener | The createListenerMiddleware |
Complete Example Session
// ============================================
// PART 1: THE CUSTOM MIDDLEWARE
// ============================================
const logger = (store) => (next) => (action) => {
console.log('dispatching:', action);
const result = next(action);
console.log('next state:', store.getState());
return result;
};
export default logger;
// ============================================
// PART 2: THE MIDDLEWARE REGISTRATION
// ============================================
import { configureStore } from '@reduxjs/toolkit';
import todosReducer from '../features/todos/todosSlice';
import logger from './logger';
export const store = configureStore({
reducer: { todos: todosReducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});
// ============================================
// PART 3: THE BASIC THUNK
// ============================================
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
const response = await fetch('/api/todos');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
});
// ============================================
// PART 4: THE THUNK WITH THE ARGUMENT
// ============================================
export const fetchTodo = createAsyncThunk(
'todos/fetchTodo',
async (todoId, { getState, rejectWithValue, signal }) => {
const { auth } = getState();
const response = await fetch(`/api/todos/${todoId}`, {
headers: { Authorization: `Bearer ${auth.token}` },
signal,
});
if (!response.ok) return rejectWithValue(await response.json());
return response.json();
}
);
// ============================================
// PART 5: THE EXTRAREDUCERS
// ============================================
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], status: 'idle', error: null },
reducers: { ... },
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload ?? action.error.message;
});
},
});
// ============================================
// PART 6: THE DISPATCH
// ============================================
function TodoLoader() {
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(fetchTodos());
}, [dispatch]);
return null;
}
// ============================================
// PART 7: THE UNWRAP
// ============================================
async function handleRefresh() {
try {
const todos = await dispatch(fetchTodos()).unwrap();
console.log('Fetched:', todos);
} catch (error) {
console.error('Failed:', error);
}
}
// ============================================
// PART 8: THE CHAINED THUNK
// ============================================
export const addTodoAndFetch = (text) => async (dispatch) => {
await dispatch(addTodoAsync(text)).unwrap();
await dispatch(fetchTodos()).unwrap();
};
// ============================================
// PART 9: THE LISTENER MIDDLEWARE
// ============================================
import { createListenerMiddleware } from '@reduxjs/toolkit';
const listenerMiddleware = createListenerMiddleware();
listenerMiddleware.startListening({
actionCreator: addTodo,
effect: async (action, listenerApi) => {
await listenerApi.delay(500);
listenerApi.dispatch(fetchTodos());
},
});
// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================
// 1. Use the middleware for the side effects.
// 2. Use the createAsyncThunk for the async.
// 3. Use the extraReducers for the three states.
// 4. Use the rejectWithValue for the custom error.
// 5. Use the .unwrap() for the promise.
// 6. Use the condition for the skip.
// 7. Use the listener middleware for the reactions.
// 8. Use the chained thunk for the sequences.
// 9. Use the optimistic pattern for the immediate UI.
// 10. Use the typed hooks for the TypeScript.
The ten parts showed the custom middleware, the middleware registration, the basic thunk, the thunk with the argument, the extraReducers, the dispatch, the .unwrap(), the chained thunk, the listener middleware, and the complete pattern.
Quick Reference
The Middleware
| Element | Purpose |
|---|---|
store | The dispatch and the getState |
next | The pass the action on |
action | The dispatched |
next(action) | The return the result |
The createAsyncThunk
| Element | Purpose |
|---|---|
| The prefix | The action type |
| The payload creator | The async function |
.pending | The loading |
.fulfilled | The success |
.rejected | The failure |
The Thunk API
| Property | Purpose |
|---|---|
dispatch | The dispatch |
getState | The read |
rejectWithValue | The custom error |
signal | The abort |
extra | The extra argument |
requestId | The unique |
The extraReducers
| Case | State |
|---|---|
.pending | The loading |
.fulfilled | The success |
.rejected | The failure |
The Patterns
| Pattern | Mechanism |
|---|---|
| The fetch | The createAsyncThunk |
| The chain | The dispatch inside |
| The optimistic | The local + the API |
| The listener | The createListenerMiddleware |
Best Practices
✅ Do This:
// Use the createAsyncThunk for the async.
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => { ... }); // ✅
// Use the extraReducers for the three states.
builder.addCase(fetchTodos.pending, ...).addCase(fetchTodos.fulfilled, ...); // ✅
// Use the rejectWithValue for the custom error.
if (!response.ok) return rejectWithValue(await response.json()); // ✅
// Use the .unwrap() for the promise.
const result = await dispatch(fetchTodos()).unwrap(); // ✅
// Use the condition for the skip.
condition: (arg, { getState }) => getState().todos.status !== 'loading', // ✅
// Use the listener middleware for the reactions.
listenerMiddleware.startListening({ actionCreator: addTodo, effect: ... }); // ✅
// Use the middleware for the logging.
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger), // ✅
❌ Don’t Do This:
// Don't fetch in the reducer.
reducers: {
fetchTodos: (state) => {
fetch('/api/todos'); // ❌ The reducer must be pure
},
},
// Don't forget the extraReducers.
createAsyncThunk('todos/fetchTodos', ...); // ❌ The state is not updated
// Don't use the .unwrap() without the try/catch.
await dispatch(fetchTodos()).unwrap(); // ❌ The unhandled rejection
// Don't dispatch the thunk in the render.
function Component() {
dispatch(fetchTodos()); // ❌ The render loop
return null;
}
// Don't ignore the condition.
dispatch(fetchTodos()); // ⚠️ The duplicate request
// Don't use the thunk for the pure state.
dispatch({ type: 'todos/setFilter', payload: 'all' }); // ✅ The plain action
// Don't replace the default middleware.
middleware: [logger], // ❌ The thunk is removed
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The state not updated | No extraReducers | Add the cases |
| The unhandled rejection | No .unwrap() try | Add the try/catch |
| The duplicate request | No condition | Add the condition |
| The render loop | The dispatch in the render | Dispatch in the effect |
| The thunk removed | The middleware replaced | Use getDefaultMiddleware |
| The infinite loop | The listener dispatching the same | Guard the action |
| The error lost | The rejectWithValue ignored | Read the action.payload |
Real-World Examples
1. The Basic Thunk
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => { ... });
2. The extraReducers
builder.addCase(fetchTodos.fulfilled, (state, action) => { state.items = action.payload; });
3. The rejectWithValue
if (!response.ok) return rejectWithValue(await response.json());
4. The unwrap
const result = await dispatch(fetchTodos()).unwrap();
5. The Condition
condition: (arg, { getState }) => getState().todos.status !== 'loading'
6. The Logger Middleware
const logger = (store) => (next) => (action) => { ... };
7. The Listener Middleware
listenerMiddleware.startListening({ actionCreator: addTodo, effect: ... });
8. The Chained Thunk
export const addAndFetch = (text) => async (dispatch) => { ... };
9. The Optimistic
dispatch(addTodoLocal(tempId));
try { const saved = await addTodoApi(text); dispatch(replaceTodo(...)); }
catch { dispatch(removeTodoLocal(tempId)); }
10. The Complete Pattern
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => { ... });
Visual
The Middleware Chain
┌────────────────────────────────────────────────────────────┐
│ THE MIDDLEWARE CHAIN │
│ │
│ dispatch(action) │
│ ↓ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Middleware 1 (the thunk) │ │
│ │ ↓ │ │
│ │ Middleware 2 (the logger) │ │
│ │ ↓ │ │
│ │ Middleware 3 (the custom) │ │
│ │ ↓ │ │
│ │ Reducer │ │
│ │ ↓ │ │
│ │ Store │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The action passes through the chain. Each middleware │
│ can inspect, transform, or block. │
└────────────────────────────────────────────────────────────┘
The Thunk Lifecycle
┌────────────────────────────────────────────────────────────┐
│ THE THUNK LIFECYCLE │
│ │
│ dispatch(fetchTodos()) │
│ ↓ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ todos/fetchTodos/pending │ │
│ │ → state.status = 'loading' │ │
│ └────────────────────┬─────────────────────────────┘ │
│ │ │
│ │ The API call │
│ v │
│ ┌─────────────┴─────────────┐ │
│ │ │ │
│ v v │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ fulfilled │ │ rejected │ │
│ │ status = │ │ status = │ │
│ │ 'succeeded' │ │ 'failed' │ │
│ │ items = │ │ error = │ │
│ │ payload │ │ message │ │
│ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────┘
The createAsyncThunk
┌────────────────────────────────────────────────────────────┐
│ THE createAsyncThunk │
│ │
│ createAsyncThunk( │
│ 'todos/fetchTodos', ← the prefix │
│ async (arg, thunkAPI) => { ← the payload creator │
│ const response = await fetch('/api/todos'); │
│ return response.json(); │
│ } │
│ ); │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Generates: │ │
│ │ todos/fetchTodos/pending │ │
│ │ todos/fetchTodos/fulfilled │ │
│ │ todos/fetchTodos/rejected │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
The Listener Middleware
┌────────────────────────────────────────────────────────────┐
│ THE LISTENER MIDDLEWARE │
│ │
│ listenerMiddleware.startListening({ │
│ actionCreator: addTodo, │
│ effect: async (action, listenerApi) => { │
│ await listenerApi.delay(500); │
│ listenerApi.dispatch(fetchTodos()); │
│ }, │
│ }); │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The effect runs after the addTodo action. │ │
│ │ The delay is the async. │ │
│ │ The dispatch is the follow-up. │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Middleware | The function between the dispatch and the reducer |
store => next => action | The middleware signature |
createAsyncThunk | The async action creator |
.pending | The loading |
.fulfilled | The success |
.rejected | The failure |
rejectWithValue | The custom error |
.unwrap() | The promise |
condition | The skip |
createListenerMiddleware | The reaction |
Key takeaways:
- The middleware sits between the dispatch and the reducer. The signature is
store => next => action. The middleware can inspect, transform, block, delay, or dispatch the follow-up action. - The
createAsyncThunkgenerates the three lifecycle actions. Thepending, thefulfilled, and therejectedcome from the one async function. The slice’sextraReducershandles the three. - The payload creator receives the argument and the thunk API. The
getState, thedispatch, therejectWithValue, thesignal, and theextraare the API. Thesignalis for the cancellation. - The
rejectWithValuesets the custom error. Theaction.payloadis the value, and theaction.error.messageis the standard. The component reads theaction.payloadfor the friendly message. - The
.unwrap()returns the promise. The resolved value is the payload, and the rejected is the error. Thetry/catchhandles the two. - The
conditionskips the thunk. The function returns false, and the thunk does not run. The pattern avoids the duplicate requests. - The listener middleware is the alternative for the reactions. The
startListeningregisters the effect, and the effect runs after the action. The middleware is the modern replacement for thestore.subscribe. - The typed hooks are the TypeScript convention. The
useAppDispatchand theuseAppSelectorcarry the types from the store.
Remember: The Redux middleware and the async thunks are the mechanisms for the side effects and the async logic. The middleware is the function between the dispatch and the reducer, and the createAsyncThunk generates the lifecycle actions from the one async function. The extraReducers handles the three states, and the rejectWithValue sets the custom error. The result is the store where the async is the first-class, the side effects are the visible, and the DevTools replay the flow.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!