| |

React 69 ⚛️ Redux Middleware and Async Thunks

⏱️ Estimated read: 14 min | |

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

ElementPurpose
storeThe dispatch and the getState
nextThe pass the action on
actionThe dispatched
next(action)The return the result
store.dispatchThe 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

PropertyPurpose
dispatchThe dispatch the action
getStateThe read the state
rejectWithValueThe custom rejection
signalThe abort signal
extraThe extra argument
requestIdThe 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

ElementPurpose
The prefixThe action type
The payload creatorThe async function
.pendingThe loading
.fulfilledThe success
.rejectedThe failure
rejectWithValueThe custom error
.unwrap()The promise
conditionThe 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

PatternMechanism
The fetch and storeThe createAsyncThunk
The chained thunkThe dispatch inside
The optimisticThe local action + the API
The error handlingThe rejectWithValue
The listenerThe 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

ElementPurpose
storeThe dispatch and the getState
nextThe pass the action on
actionThe dispatched
next(action)The return the result

The createAsyncThunk

ElementPurpose
The prefixThe action type
The payload creatorThe async function
.pendingThe loading
.fulfilledThe success
.rejectedThe failure

The Thunk API

PropertyPurpose
dispatchThe dispatch
getStateThe read
rejectWithValueThe custom error
signalThe abort
extraThe extra argument
requestIdThe unique

The extraReducers

CaseState
.pendingThe loading
.fulfilledThe success
.rejectedThe failure

The Patterns

PatternMechanism
The fetchThe createAsyncThunk
The chainThe dispatch inside
The optimisticThe local + the API
The listenerThe 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

PitfallWhy It HappensFix
The state not updatedNo extraReducersAdd the cases
The unhandled rejectionNo .unwrap() tryAdd the try/catch
The duplicate requestNo conditionAdd the condition
The render loopThe dispatch in the renderDispatch in the effect
The thunk removedThe middleware replacedUse getDefaultMiddleware
The infinite loopThe listener dispatching the sameGuard the action
The error lostThe rejectWithValue ignoredRead 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

ItemValue
MiddlewareThe function between the dispatch and the reducer
store => next => actionThe middleware signature
createAsyncThunkThe async action creator
.pendingThe loading
.fulfilledThe success
.rejectedThe failure
rejectWithValueThe custom error
.unwrap()The promise
conditionThe skip
createListenerMiddlewareThe 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 createAsyncThunk generates the three lifecycle actions. The pending, the fulfilled, and the rejected come from the one async function. The slice’s extraReducers handles the three.
  • The payload creator receives the argument and the thunk API. The getState, the dispatch, the rejectWithValue, the signal, and the extra are the API. The signal is for the cancellation.
  • The rejectWithValue sets the custom error. The action.payload is the value, and the action.error.message is the standard. The component reads the action.payload for the friendly message.
  • The .unwrap() returns the promise. The resolved value is the payload, and the rejected is the error. The try/catch handles the two.
  • The condition skips 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 startListening registers the effect, and the effect runs after the action. The middleware is the modern replacement for the store.subscribe.
  • The typed hooks are the TypeScript convention. The useAppDispatch and the useAppSelector carry 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!