Skip to content

Redux - Middleware

By default, Redux’s dispatch mechanism is synchronous. When you dispatch an action, the state is updated immediately by the reducers. But what about asynchronous operations like fetching data from an API?

This is where middleware comes in. Middleware provides a third-party extension point between dispatching an action and the moment it reaches the reducer. It allows you to intercept actions, modify them, delay them, dispatch other actions, or interact with external services.

Think of middleware as a chain of functions that sit between store.dispatch(action) and your root reducer. Each middleware can inspect the action, perform tasks, and then pass the action (or a different action, or nothing) along to the next middleware in the chain, eventually reaching the reducer.

Common use cases for middleware include:

  • Handling asynchronous operations (e.g., API calls).
  • Logging actions and state changes.
  • Reporting errors.
  • Making API calls based on dispatched actions.
  • Routing.

Redux provides the applyMiddleware function to enhance the store with middleware. However, the recommended way to set up a Redux store is using Redux Toolkit’s configureStore function, which simplifies middleware setup.

// Using Redux Toolkit (Recommended)
import { configureStore } from '@reduxjs/toolkit';
import logger from 'redux-logger'; // Example middleware
import rootReducer from './reducers'; // Your combined reducers
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger), // Adds logger to default middleware
});
// Old way (for understanding)
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk'; // Example middleware
import rootReducer from './reducers';
const store = createStore(rootReducer, applyMiddleware(thunk, logger));

configureStore automatically includes several helpful middlewares by default, most notably redux-thunk. You can customize the middleware stack using the middleware option, often using getDefaultMiddleware to include the defaults plus your custom ones.

A Redux middleware is a higher-order function with the signature: store => next => action => { ... }.

  • store: Provides access to the store’s getState and dispatch methods.
  • next: A function that passes the action to the next middleware in the chain, or to the reducer if it’s the last one. You must call next(action) for the action to eventually reach the reducer (unless you intend to stop it).
  • action: The action object that was dispatched.

Let’s write a simple custom logger middleware:

const simpleLogger = store => next => action => {
console.log('Dispatching action:', action);
const result = next(action); // Pass the action to the next middleware/reducer
console.log('Next state:', store.getState());
return result; // Return the result of next(action)
};
// Usage with configureStore:
const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(simpleLogger),
});

redux-thunk is the standard middleware for basic asynchronous logic. It allows action creators to return a function (a “thunk”) instead of a plain action object. This function receives dispatch and getState as arguments, allowing it to dispatch actions asynchronously (e.g., after an API call).

Example Thunk Action Creator:

// actions/userActions.js
import { userApi } from './api'; // Assume this contains API call functions
// Action types (often generated by createSlice)
const USER_FETCH_REQUEST = 'user/fetchRequest';
const USER_FETCH_SUCCESS = 'user/fetchSuccess';
const USER_FETCH_FAILURE = 'user/fetchFailure';
// Thunk Action Creator
export const fetchUser = (userId) => async (dispatch, getState) => {
dispatch({ type: USER_FETCH_REQUEST });
try {
const user = await userApi.fetchById(userId);
dispatch({ type: USER_FETCH_SUCCESS, payload: user });
} catch (error) {
dispatch({ type: USER_FETCH_FAILURE, payload: error.message });
}
};

You would dispatch this thunk like a regular action: store.dispatch(fetchUser(123));

Modern Async with Redux Toolkit: createAsyncThunk

Section titled “Modern Async with Redux Toolkit: createAsyncThunk”

Redux Toolkit provides createAsyncThunk to simplify the standard async request lifecycle (pending/fulfilled/rejected).

// features/users/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { userApi } from './api';
// Async thunk generated by createAsyncThunk
export const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus', // Action type prefix
async (userId, thunkAPI) => {
// First argument is the payload creator callback
const response = await userApi.fetchById(userId);
return response.data; // This becomes the 'fulfilled' action payload
// If it throws, it becomes the 'rejected' action payload
}
);
const usersSlice = createSlice({
name: 'users',
initialState: { entity: null, loading: 'idle', error: null },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state) => {
state.loading = 'pending';
state.entity = null;
state.error = null;
})
.addCase(fetchUserById.fulfilled, (state, action) => {
state.loading = 'succeeded';
state.entity = action.payload; // Payload from the async function
})
.addCase(fetchUserById.rejected, (state, action) => {
state.loading = 'failed';
state.error = action.error.message; // Error from the async function
});
},
});
export default usersSlice.reducer;
// Dispatching:
// store.dispatch(fetchUserById(123));

createAsyncThunk automatically dispatches the pending/fulfilled/rejected actions, which you can handle in your slice’s extraReducers.

For more complex asynchronous flows or managing side effects, other middleware like redux-saga (using generator functions) or redux-observable (using RxJS Observables) are available, but redux-thunk (especially via createAsyncThunk) is sufficient for most common use cases.