| | |

Redux Toolkit 17 ⚛️ Middleware Custom — Writing custom middleware for logging and side effects

⏱️ Estimated read: 12 min | |

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

LayerReceivesReturns
Outerstore (dispatch, getState)Middle function
MiddlenextAction handler
InneractionResult of next(action)

RTK 2.0 type guard

Without guardWith guard
action.type is a TS errorisAction(action) narrows to Action
action is unknownaction.type is a string

Store configuration

MethodOrder
getDefaultMiddleware().concat(custom)Custom runs after defaults
getDefaultMiddleware().prepend(custom)Custom runs before defaults
middleware: () => [custom]Defaults removed (avoid)

Middleware use cases

Use casePattern
LoggingWrap next with console output
AnalyticsTrack action types in a Set
Side effectPerform async work after next
Action modificationModify action before next
Action cancellationReturn 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

PitfallWhy It HappensFix
action.type TS errorRTK 2.0 types action as unknownUse isAction(action) guard
Default checks missingMiddleware list replaced instead of extendedUse getDefaultMiddleware().concat(...)
Action not reaching reducernext(action) not calledReturn next(action)
Logging in productionNo environment checkGate with process.env.NODE_ENV
Side effect runs twiceMiddleware and component both dispatchMove 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

ItemValue
Middleware signaturestore => next => action => {}
RTK 2.0 action typeunknown
Type guardisAction(action)
Add after defaultsgetDefaultMiddleware().concat(...)
Add before defaultsgetDefaultMiddleware().prepend(...)
Logging patternWrap next with console output
Side-effect patternPerform work after next
Default checksImmutability, 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 of next(action) if the action should reach the reducer .
  • RTK 2.0 types action as unknown. Accessing action.type without isAction(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 .
  • concat adds custom middleware after the defaults; prepend adds 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 next with console.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. Use concat or prepend to 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.

Was this helpful?


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!