Redux Toolkit 17 ⚛️ Middleware Custom — Writing custom middleware for logging and side effects
A custom middleware is a function that sits between dispatch and the reducers. It receives every action, can inspect it, can modify it, can delay it, can perform side effects, and can pass it along to the next middleware or the reducer. Redux Toolkit’s configureStore includes a default middleware stack (thunk, immutability checks, serializability checks), and custom middleware is added by concatenating onto that stack or prepending to it. The two most common custom middleware patterns are logging—recording every action and the state change it produces—and side effects—reacting to an action with an async operation that is not a state update.
This chapter covers the middleware signature, the logging pattern that logs before and after the reducer, the side-effect pattern that performs async work in response to an action, the RTK 2.0 isAction type guard that makes action.type safe to access, and the store configuration that keeps the default middleware intact while adding the custom ones.
Key point: In RTK 2.0, action is typed as unknown in middleware. Accessing action.type without the isAction() guard is a TypeScript error. The guard is what makes the logging middleware type-safe.
Why custom middleware exists
The side-effect problem. A reducer must be pure. It cannot fetch data, write to localStorage, or call an analytics service. Those operations are side effects, and they belong in middleware. A middleware can intercept an action like todos/addTodo, perform the fetch, and dispatch a success or failure action when the request completes. The reducer only sees the final state transitions, not the async work that produced them .
The logging problem. When a dispatched action produces an unexpected state change, the fastest way to diagnose the problem is to see the action and the state before and after. A logging middleware wraps next(action) with console.group, logs the action, logs the previous state, calls next, logs the next state, and closes the group. The output is a readable trace of every state transition .
The default middleware problem. configureStore includes middleware that catches common mistakes: accidental state mutation, non-serializable values, and dispatching an action creator without calling it. A custom middleware added with getDefaultMiddleware().concat(...) runs after these checks. A custom middleware that needs to see actions before the serializability check (for example, one that handles non-serializable payloads) must use prepend instead .
a. The middleware signature
A middleware is a curried function with three layers. The outermost receives the store API (dispatch, getState). The middle layer receives next, the next middleware in the chain. The innermost receives the action.
import type { Middleware } from '@reduxjs/toolkit'
const exampleMiddleware: Middleware = (store) => (next) => (action) => {
// before the reducer
const result = next(action)
// after the reducer
return result
}
The next(action) call passes the action to the next middleware or the reducer. If the middleware does not call next, the action is swallowed and the reducer never sees it. The return value of next is the action that was dispatched (possibly modified by an earlier middleware), and it should be returned from the middleware so that the caller receives the correct value .
b. The logging middleware
A logging middleware wraps next with console output. The RTK 2.0 isAction guard is required because action is typed as unknown. Without the guard, action.type is a TypeScript error.
// store/middleware/logger-middleware.ts
import type { Middleware } from '@reduxjs/toolkit'
import { isAction } from '@reduxjs/toolkit'
import type { RootState } from '../index'
const ENABLE_LOGGING = process.env.NODE_ENV === 'development'
export const loggerMiddleware: Middleware<{}, RootState> =
(store) => (next) => (action) => {
if (!ENABLE_LOGGING) {
return next(action)
}
if (isAction(action)) {
console.group(action.type)
console.log('Dispatching:', action)
console.log('Previous state:', store.getState())
const result = next(action)
console.log('Next state:', store.getState())
console.groupEnd()
return result
}
return next(action)
}
The isAction guard checks that the value has a type property that is a string. If the value is a thunk (a function), the guard returns false, and the middleware passes it along without logging. The ENABLE_LOGGING constant ensures that the logging code is tree-shaken in production builds, so the console output never ships to users .
c. The side-effect middleware
A side-effect middleware reacts to a specific action by performing async work. The middleware inspects the action, starts the operation, and optionally dispatches a new action when the operation completes.
// store/middleware/analytics-middleware.ts
import type { Middleware } from '@reduxjs/toolkit'
import { isAction } from '@reduxjs/toolkit'
const TRACKED_ACTIONS = new Set([
'auth/login/fulfilled',
'todos/addTodo',
'users/userRemoved',
])
export const analyticsMiddleware: Middleware = () => (next) => (action) => {
if (isAction(action) && TRACKED_ACTIONS.has(action.type)) {
console.log('[Analytics]', action.type)
// In a real app: send to an analytics service
}
return next(action)
}
This middleware does not block the action. It logs the tracked action and passes it along. For an async operation, the middleware can call fetch without awaiting it, or it can dispatch a new action with the result. The key is that the middleware is not a reducer; it is allowed to have side effects .
For a more structured side-effect pattern, RTK provides createListenerMiddleware. The documentation recommends it over custom middleware for most side-effect use cases, because it handles cancellation, concurrency, and async workflows with a tested API . A custom middleware is appropriate for a single, simple side effect—an analytics call, a localStorage write—or for modifying or cancelling actions .
d. Adding the middleware to the store
The middleware is added through the middleware option of configureStore. The callback receives getDefaultMiddleware, which returns the default stack. concat adds the custom middleware after the defaults; prepend adds it before.
// store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import { loggerMiddleware } from './middleware/logger-middleware'
import { analyticsMiddleware } from './middleware/analytics-middleware'
import { todosReducer } from './slices/todos-slice'
export const store = configureStore({
reducer: {
todos: todosReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware, analyticsMiddleware),
})
The concat order determines execution order. The default middleware runs first, then loggerMiddleware, then analyticsMiddleware. If the analytics middleware should see the action before the serializability check rejects it, it must be prepended instead. The documentation warns that a custom middleware returning a list without the defaults removes the immutability and serializability checks silently .
Complete Example Session
// ============================================
// PART 1: THE MIDDLEWARE SIGNATURE
// ============================================
import type { Middleware } from '@reduxjs/toolkit'
const example: Middleware = (store) => (next) => (action) => {
const result = next(action)
return result
}
// store: { dispatch, getState }
// next: the next middleware or the reducer
// action: the dispatched action (unknown in RTK 2.0)
// ============================================
// PART 2: THE ISACTION GUARD
// ============================================
import { isAction } from '@reduxjs/toolkit'
if (isAction(action)) {
console.log(action.type) // safe
}
// Without the guard, action.type is a TS error.
// ============================================
// PART 3: THE LOGGING MIDDLEWARE
// ============================================
import type { Middleware } from '@reduxjs/toolkit'
import { isAction } from '@reduxjs/toolkit'
import type { RootState } from '../index'
export const loggerMiddleware: Middleware<{}, RootState> =
(store) => (next) => (action) => {
if (!isAction(action)) {
return next(action)
}
console.group(action.type)
console.log('Dispatching:', action)
console.log('Previous state:', store.getState())
const result = next(action)
console.log('Next state:', store.getState())
console.groupEnd()
return result
}
// ============================================
// PART 4: THE ANALYTICS MIDDLEWARE
// ============================================
const TRACKED_ACTIONS = new Set([
'auth/login/fulfilled',
'todos/addTodo',
])
export const analyticsMiddleware: Middleware = () => (next) => (action) => {
if (isAction(action) && TRACKED_ACTIONS.has(action.type)) {
console.log('[Analytics]', action.type)
}
return next(action)
}
// ============================================
// PART 5: THE SIDE-EFFECT MIDDLEWARE
// ============================================
export const persistMiddleware: Middleware = (store) => (next) => (action) => {
const result = next(action)
if (isAction(action) && action.type === 'settings/themeChanged') {
localStorage.setItem('theme', store.getState().settings.theme)
}
return result
}
// The side effect runs after the reducer.
// The action is not modified.
// ============================================
// PART 6: STORE CONFIGURATION
// ============================================
import { configureStore } from '@reduxjs/toolkit'
export const store = configureStore({
reducer: { todos: todosReducer },
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware, analyticsMiddleware),
})
// ============================================
// PART 7: PREPEND VS CONCAT
// ============================================
// concat: custom middleware runs after defaults
getDefaultMiddleware().concat(customMiddleware)
// prepend: custom middleware runs before defaults
getDefaultMiddleware().prepend(customMiddleware)
// The serializability check runs before concat middleware.
// A custom middleware that handles non-serializable
// payloads must be prepended.
// ============================================
// PART 8: CONDITIONAL LOGGING
// ============================================
const ENABLE_LOGGING = process.env.NODE_ENV === 'development'
export const loggerMiddleware: Middleware = (store) => (next) => (action) => {
if (!ENABLE_LOGGING) return next(action)
// ... log
}
// The logging code is removed in production builds.
// ============================================
// PART 9: THE DEFAULT MIDDLEWARE WARNING
// ============================================
// Wrong: returns a list without the defaults
middleware: () => [loggerMiddleware]
// This removes thunk, immutability, and serializability checks.
// Correct: concat onto the defaults
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(loggerMiddleware)
// ============================================
// PART 10: THE COMPLETE STORE
// ============================================
import { configureStore } from '@reduxjs/toolkit'
import { loggerMiddleware } from './middleware/logger-middleware'
import { analyticsMiddleware } from './middleware/analytics-middleware'
export const store = configureStore({
reducer: {
todos: todosReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware, analyticsMiddleware),
})
The ten parts covered the signature, the isAction guard, the logging middleware, the analytics middleware, the side-effect middleware, store configuration, prepend vs concat, conditional logging, the default middleware warning, and the complete store.
Quick Reference
Middleware signature
| Layer | Receives | Returns |
|---|---|---|
| Outer | store (dispatch, getState) | Middle function |
| Middle | next | Action handler |
| Inner | action | Result of next(action) |
RTK 2.0 type guard
| Without guard | With guard |
|---|---|
action.type is a TS error | isAction(action) narrows to Action |
action is unknown | action.type is a string |
Store configuration
| Method | Order |
|---|---|
getDefaultMiddleware().concat(custom) | Custom runs after defaults |
getDefaultMiddleware().prepend(custom) | Custom runs before defaults |
middleware: () => [custom] | Defaults removed (avoid) |
Middleware use cases
| Use case | Pattern |
|---|---|
| Logging | Wrap next with console output |
| Analytics | Track action types in a Set |
| Side effect | Perform async work after next |
| Action modification | Modify action before next |
| Action cancellation | Return without calling next |
Best Practices
✅ Do This:
// Use isAction before accessing action.type
if (isAction(action)) { console.log(action.type) } // ✅
// Concat onto the defaults to keep the checks
getDefaultMiddleware().concat(loggerMiddleware) // ✅
// Enable logging only in development
const ENABLE_LOGGING = process.env.NODE_ENV === 'development' // ✅
// Return the result of next(action)
return next(action) // ✅
❌ Don’t Do This:
// Access action.type without the guard
console.log(action.type) // ❌ (TS error in RTK 2.0)
// Return a middleware list without the defaults
middleware: () => [loggerMiddleware] // ❌ (removes checks)
// Perform async work inside a reducer
fetch('/api/data') // ❌ (reducers must be pure)
// Swallow an action without a reason
return // ❌ (the reducer never sees it)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
action.type TS error | RTK 2.0 types action as unknown | Use isAction(action) guard |
| Default checks missing | Middleware list replaced instead of extended | Use getDefaultMiddleware().concat(...) |
| Action not reaching reducer | next(action) not called | Return next(action) |
| Logging in production | No environment check | Gate with process.env.NODE_ENV |
| Side effect runs twice | Middleware and component both dispatch | Move the side effect to one place |
Real-World Examples
1. Basic logger
const logger: Middleware = (store) => (next) => (action) => {
console.log(action)
return next(action)
}
2. Logger with state
const logger: Middleware = (store) => (next) => (action) => {
console.log('before', store.getState())
const result = next(action)
console.log('after', store.getState())
return result
}
3. isAction guard
if (isAction(action)) { console.log(action.type) }
4. Analytics set
const TRACKED = new Set(['auth/login/fulfilled'])
5. Side effect after next
const result = next(action)
localStorage.setItem('key', store.getState().value)
6. Concat to store
getDefaultMiddleware().concat(logger, analytics)
7. Prepend to store
getDefaultMiddleware().prepend(custom)
8. Conditional logging
if (!ENABLE_LOGGING) return next(action)
9. Return next result
return next(action)
10. Complete store
configureStore({ reducer, middleware: (g) => g().concat(logger) })
Visual
The middleware chain
┌─────────────────────────────────────────────────────────────┐
│ MIDDLEWARE CHAIN │
│ │
│ dispatch(action) │
│ │ │
│ ▼ │
│ thunk middleware │
│ │ │
│ ▼ │
│ logger middleware │
│ │ │
│ ├── log "before" │
│ │ │
│ ▼ │
│ analytics middleware │
│ │ │
│ ▼ │
│ serializability check │
│ │ │
│ ▼ │
│ reducer │
│ │ │
│ ▼ │
│ logger logs "after" │
│ │ │
│ ▼ │
│ return │
└─────────────────────────────────────────────────────────────┘
The logging flow
┌─────────────────────────────────────────────────────────────┐
│ LOGGING MIDDLEWARE │
│ │
│ action arrives │
│ │ │
│ ├── isAction? │
│ │ no → next(action) │
│ │ yes → console.group(action.type) │
│ │ │
│ ├── log "Dispatching" + action │
│ ├── log "Previous state" │
│ │ │
│ ├── next(action) ← reducer runs here │
│ │ │
│ ├── log "Next state" │
│ ├── console.groupEnd() │
│ │ │
│ ▼ │
│ return result │
└─────────────────────────────────────────────────────────────┘
concat vs prepend
┌─────────────────────────────────────────────────────────────┐
│ CONCAT PREPEND │
│ │
│ defaults first custom first │
│ then custom then defaults │
│ │
│ Serializability check Custom sees action │
│ runs before custom before the check │
│ │
│ Use for logging, Use for middleware │
│ analytics that handles non- │
│ serializable payloads │
└─────────────────────────────────────────────────────────────┘
isAction guard
┌─────────────────────────────────────────────────────────────┐
│ RTK 2.0 ACTION TYPE │
│ │
│ action: unknown │
│ │ │
│ ├── isAction(action)? │
│ │ no → action is a thunk or other value │
│ │ yes → action.type is safe to access │
│ │ │
│ Without guard: action.type is a TypeScript error. │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Middleware signature | store => next => action => {} |
| RTK 2.0 action type | unknown |
| Type guard | isAction(action) |
| Add after defaults | getDefaultMiddleware().concat(...) |
| Add before defaults | getDefaultMiddleware().prepend(...) |
| Logging pattern | Wrap next with console output |
| Side-effect pattern | Perform work after next |
| Default checks | Immutability, serializability, thunk |
Key takeaways:
- A middleware is a function that wraps
next(action). It can run code before the reducer, after the reducer, or both. It can modify the action, cancel it, or perform a side effect. The only requirement is that it returns the result ofnext(action)if the action should reach the reducer . - RTK 2.0 types
actionasunknown. Accessingaction.typewithoutisAction(action)is a TypeScript error. The guard checks that the value is a Redux action before narrowing the type. This prevents runtime errors when a thunk or a non-action value passes through the middleware chain . concatadds custom middleware after the defaults;prependadds it before. The default middleware includes thunk, immutability checks, and serializability checks. A custom middleware that needs to see actions before the serializability check must be prepended. A logging middleware that only needs the final action can be concatenated .- Logging middleware wraps
nextwithconsole.group. The group header is the action type. The logs show the action, the previous state, the next state, and the group closes. This produces a readable trace of every state transition without modifying the action . - Side-effect middleware performs async work outside the reducer. A reducer must be pure. A middleware can fetch, write to localStorage, or send analytics data. The action is passed along with
next(action), and the side effect runs before or after the reducer, depending on where the code is placed . - The default middleware should not be removed. Returning a list without
getDefaultMiddleware()silently removes the immutability and serializability checks. Useconcatorprependto extend the defaults, not replace them .
Remember: Custom middleware is a wrapper around dispatch. It sees every action, can act on it, and passes it along. The logging pattern wraps next with console output. The side-effect pattern performs async work outside the reducer. The RTK 2.0 isAction guard is mandatory for type-safe access to action.type. And the store configuration must use concat or prepend to keep the default checks intact. For complex side effects—cancellation, debouncing, async workflows—the RTK documentation recommends createListenerMiddleware over custom middleware, because it handles the edge cases that a hand-rolled middleware will eventually encounter.
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!