| | |

Zustand 09 ⚛️ Middleware DevTools — Integrating Redux DevTools for debugging

⏱️ Estimated read: 12 min | |

The Redux DevTools browser extension is a debugging tool that records every state change, displays the action that caused it, and allows time-travel navigation through the recorded history. It was built for Redux, but Zustand’s devtools middleware connects to the same extension, so a Zustand store can be inspected with the same interface . The previous chapter covered the Immer middleware for simplifying updates; this chapter covers the middleware that makes those updates visible: the devtools middleware, the action names that label each change, the enabled flag that disables the middleware in production, and the composition order when combined with other middleware.

The middleware is opt-in. Without it, the store works normally but the DevTools extension shows nothing for it. With it, every set call appears as an entry in the extension’s timeline, with the action name, the previous state, the new state, and the diff between them.

Key point: The devtools middleware accepts an optional action name as the third argument to set. set({ count: 1 }, false, 'counter/increment') labels the change with the action name, which is what appears in the DevTools timeline. Without the action name, the entry is labeled with a generic name that is difficult to distinguish from other entries. The enabled option should be set to process.env.NODE_ENV === 'development' so that the middleware is active only in development builds.


Why the devtools middleware requires action naming

The anonymous-action problem.

A set call without an action name produces a DevTools entry with a generic label, often setState or the name of the store. A timeline full of identical labels is not useful for debugging; the developer cannot tell which user interaction produced which state change. The third argument to set is the action name, and it is what makes the timeline readable. set({ count: 1 }, false, 'counter/increment') produces an entry labeled counter/increment, which identifies the action that caused the change .

The production-cost problem.

The DevTools middleware adds overhead: it serializes the state for every change, sends it to the extension, and maintains the action history. In development, this overhead is acceptable because it is the point of the tool. In production, the extension is not connected, but the middleware still runs and still serializes the state for each change unless it is disabled. The enabled option controls whether the middleware is active, and setting it to process.env.NODE_ENV === 'development' disables it in production builds . The Zustand documentation recommends this pattern explicitly.

The middleware-order problem.

The devtools middleware must be the outermost wrapper when combined with persist and immer. It sees the state after the other middleware have processed it, so the timeline shows the final state rather than the draft or the pre-serialization state. The order is devtools(persist(immer(...))), read from the outside in . Reversing the order produces entries that do not reflect the actual state changes.


a. Enabling the middleware and naming actions

The devtools middleware wraps the state creator and accepts an options object. The name option gives the store a name that appears in the DevTools extension.

import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

export const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,

      // Third argument is the action name shown in DevTools
      increment: () => set((state) => ({ count: state.count + 1 }), false, 'counter/increment'),
      decrement: () => set((state) => ({ count: state.count - 1 }), false, 'counter/decrement'),
      reset: () => set({ count: 0 }, false, 'counter/reset'),
    }),
    { name: 'CounterStore' }   // Store name in DevTools
  )
)

The set function accepts three arguments when the devtools middleware is active: the partial state or updater, a replace boolean, and the action name. The replace boolean is almost always false; setting it to true replaces the entire state instead of merging, which removes the actions unless they are re-included. The action name is the string that appears in the DevTools timeline .

The name option in the middleware configuration is the store’s name in the extension’s dropdown. A descriptive name like CounterStore or AuthStore makes it easier to find the store when multiple stores are connected .


b. Disabling the middleware in production

The enabled option controls whether the middleware is active. Setting it to false disables the DevTools connection and the overhead that comes with it.

export const useCounterStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 }), false, 'counter/increment'),
    }),
    {
      name: 'CounterStore',
      enabled: process.env.NODE_ENV === 'development',
    }
  )
)

The process.env.NODE_ENV variable is replaced at build time by the bundler, so the condition is evaluated statically. In a production build, the expression becomes enabled: false, and the middleware is bypassed . In a development build, it becomes enabled: true, and the middleware is active.

The anonymousActionType option sets the label for actions that do not provide a name. It is a fallback for set calls that omit the third argument, and its default is the store’s name. Setting it to a descriptive string like 'unknown' or 'anonymous' makes it clear which entries are unlabeled .


c. Combining devtools with persist and immer

The middleware order determines what each middleware sees. The devtools middleware should be the outermost wrapper so that it sees the final state after the other middleware have processed it.

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface State {
  count: number
  increment: () => void
}

export const useStore = create<State>()(
  devtools(                         // Outermost: logs the final state
    persist(                        // Middle: persists the final state
      immer((set) => ({             // Innermost: provides the draft
        count: 0,
        increment: () =>
          set((state) => {
            state.count += 1
          }, false, 'counter/increment'),
      })),
      { name: 'counter-storage' }
    ),
    { name: 'CounterStore', enabled: process.env.NODE_ENV === 'development' }
  )
)

The devtools middleware wraps persist, which wraps immer. The increment action uses the Immer draft to mutate the state, and the third argument to set provides the action name for the DevTools timeline. The persist middleware stores the final state, and the devtools middleware logs it. The composition order is the same as the one recommended for immer and persist: the middleware that transforms the state is innermost, and the middleware that observes the state is outermost .


Complete Example Session

// ============================================
// PART 1: THE STORE WITH DEVTOOLS
// ============================================
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

interface Todo {
  id: number
  text: string
  done: boolean
}

interface TodoState {
  todos: Todo[]
  addTodo: (text: string) => void
  toggleTodo: (id: number) => void
  deleteTodo: (id: number) => void
}

export const useTodoStore = create<TodoState>()(
  devtools(
    (set) => ({
      todos: [],

// ============================================
// PART 2: NAMED ACTIONS
// ============================================
      addTodo: (text) =>
        set(
          (state) => ({
            todos: [...state.todos, { id: Date.now(), text, done: false }],
          }),
          false,
          'todos/addTodo'           // Action name in DevTools
        ),

      toggleTodo: (id) =>
        set(
          (state) => ({
            todos: state.todos.map((todo) =>
              todo.id === id ? { ...todo, done: !todo.done } : todo
            ),
          }),
          false,
          'todos/toggleTodo'
        ),

      deleteTodo: (id) =>
        set(
          (state) => ({
            todos: state.todos.filter((todo) => todo.id !== id),
          }),
          false,
          'todos/deleteTodo'
        ),
    }),
    {
      name: 'TodoStore',
      enabled: process.env.NODE_ENV === 'development',
    }
  )
)

// ============================================
// PART 3: COMBINING WITH PERSIST AND IMMER
// ============================================
import { persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

export const useTaskStore = create<TaskState>()(
  devtools(
    persist(
      immer((set) => ({
        tasks: [],
        addTask: (title) =>
          set(
            (state) => {
              state.tasks.push({ id: Date.now(), title, done: false })
            },
            false,
            'tasks/addTask'
          ),
        toggleTask: (id) =>
          set(
            (state) => {
              const task = state.tasks.find((t) => t.id === id)
              if (task) task.done = !task.done
            },
            false,
            'tasks/toggleTask'
          ),
      })),
      { name: 'tasks-storage' }
    ),
    { name: 'TaskStore', enabled: process.env.NODE_ENV === 'development' }
  )
)

// ============================================
// PART 4: USING THE DEVTOOLS EXTENSION
// ============================================
// Open the browser's DevTools (F12)
// Select the "Redux" tab
// The store appears under the store name ("TodoStore", "TaskStore")
// Each set call appears in the timeline with its action name
// Click an entry to see the state before, after, and the diff
// Use the slider to time-travel through the recorded history

// ============================================
// PART 5: VERIFYING THE ACTION NAME
// ============================================
// Call an action
useTodoStore.getState().addTodo('Buy milk')
// In the DevTools timeline, the entry is labeled "todos/addTodo"
// The diff shows the new todo added to the array

// ============================================
// PART 6: ANONYMOUS ACTION FALLBACK
// ============================================
export const useCounterStore = create<{ count: number; increment: () => void }>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
      // No action name: uses anonymousActionType
    }),
    {
      name: 'CounterStore',
      anonymousActionType: 'unknown',
      enabled: process.env.NODE_ENV === 'development',
    }
  )
)

The six parts demonstrate the DevTools integration: the store with named actions, the combined middleware chain, the extension’s usage, the verification of action names, and the anonymous-action fallback. The critical step is Part 2: the third argument to set provides the action name, and without it, the timeline entry is labeled with the store’s name or the anonymous fallback, which is not useful for debugging.


Quick Reference

DevTools Options

OptionTypePurpose
nameStringStore name in DevTools dropdown
enabledBooleanEnable/disable middleware
anonymousActionTypeStringFallback label for unnamed actions

set Arguments with DevTools

ArgumentTypePurpose
1Object or functionPartial state or updater
2BooleanReplace flag (usually false)
3StringAction name for DevTools

Middleware Order

OrderCorrectWrong
Outermostdevtoolsimmer
Middlepersistpersist
Innermostimmerdevtools

Best Practices

✅ Do This:

// Wrap the state creator with devtools
create<T>()(devtools((set) => ({ ... })))                     # ✅

// Provide an action name as the third argument
set({ count: 1 }, false, 'counter/increment')                  # ✅

// Disable in production
enabled: process.env.NODE_ENV === 'development'                # ✅

// Give the store a descriptive name
{ name: 'TodoStore' }                                          # ✅

// Place devtools outermost
devtools(persist(immer((set) => ({ ... }))))                  # ✅

// Set anonymousActionType for unnamed actions
anonymousActionType: 'unknown'                                 # ✅

❌ Don’t Do This:

// Omit the action name
set({ count: 1 })    // Generic label in DevTools               # ❌

// Use replace: true
set({ count: 1 }, true, 'reset')    // Removes actions          # ❌

// Leave devtools enabled in production
// The middleware serializes state for every change             # ❌

// Place immer outside devtools
immer(devtools((set) => ({ ... })))    // DevTools sees the draft # ❌

// Forget to install the browser extension
// The middleware connects, but there is no extension to receive it # ❌

Common Pitfalls

PitfallWhy It HappensFix
DevTools shows nothingExtension not installed or disabledInstall Redux DevTools browser extension
Timeline entries are anonymousNo action name on setAdd the third argument
Actions removed after resetUsed replace: trueUse false for the replace flag
Production overheadenabled not conditionalenabled: process.env.NODE_ENV === 'development'
Timeline shows draftimmer placed outside devtoolsPlace devtools outermost
State is not serializableA function or class instance is in the stateKeep only plain data in the state

Real-World Examples

1. Basic devtools wrapper

create<State>()(devtools((set) => ({ ... })))

2. Named action

set({ count: 1 }, false, 'counter/increment')

3. Disable in production

devtools((set) => ({ ... }), { enabled: process.env.NODE_ENV === 'development' })

4. Store name

devtools((set) => ({ ... }), { name: 'TodoStore' })

5. Combined with persist

devtools(persist((set) => ({ ... }), { name: 'storage' }))

6. Combined with immer

devtools(persist(immer((set) => ({ ... }))))

7. Anonymous action fallback

devtools((set) => ({ ... }), { anonymousActionType: 'unknown' })

8. Named action with Immer

set((state) => { state.count += 1 }, false, 'counter/increment')

9. Verify in DevTools

useTodoStore.getState().addTodo('Buy milk')
// Timeline shows "todos/addTodo"

10. Time-travel debugging

// In the DevTools extension: use the slider to navigate recorded states

Visual

DevTools Middleware Flow

┌──────────────────────────────────────────────────────────────┐
│  EVERY SET CALL BECOMES A DEVTOOLS ENTRY                     │
│                                                              │
│  set({ count: 1 }, false, 'counter/increment')               │
│       │                                                      │
│       ▼                                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ devtools middleware                                    │  │
│  │ ├── captures the action name                           │  │
│  │ ├── captures the previous state                        │  │
│  │ ├── applies the update                                 │  │
│  │ ├── captures the new state                             │  │
│  │ └── sends the entry to the extension                   │  │
│  └──────────────────────────┬─────────────────────────────┘  │
│                             │                                │
│                             ▼                                │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ Redux DevTools extension                               │  │
│  │                                                        │  │
│  │ Timeline:                                              │  │
│  │  ├── counter/increment  (state before → after)         │  │
│  │  ├── counter/reset      (state before → after)         │  │
│  │  └── todos/addTodo      (state before → after)         │  │
│  │                                                        │  │
│  │ Click an entry → see the diff                          │  │
│  │ Use the slider → time-travel                           │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

Middleware Composition

┌──────────────────────────────────────────────────────────────┐
│  THE ORDER DETERMINES WHAT EACH MIDDLEWARE SEES              │
│                                                              │
│  devtools(persist(immer((set) => ({ ... }))))                │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ devtools    ← sees the final immutable state           │  │
│  │   │                                                    │  │
│  │   ▼                                                    │  │
│  │ persist     ← serializes the final immutable state     │  │
│  │   │                                                    │  │
│  │   ▼                                                    │  │
│  │ immer       ← produces the immutable state from draft  │  │
│  │   │                                                    │  │
│  │   ▼                                                    │  │
│  │ set         ← receives the draft                       │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Reversing the order causes the outer middleware to          │
│  receive the draft, which is not serializable.               │
└──────────────────────────────────────────────────────────────┘

Named vs Unnamed Actions

┌──────────────────────────────────────────────────────────────┐
│  ACTION NAMES MAKE THE TIMELINE READABLE                     │
│                                                              │
│  Without names:                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ setState                                               │  │
│  │ setState                                               │  │
│  │ setState                                               │  │
│  └────────────────────────────────────────────────────────┘  │
│  Which action caused which change? Unknown.                  │
│                                                              │
│  With names:                                                 │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ counter/increment                                      │  │
│  │ counter/decrement                                      │  │
│  │ todos/addTodo                                          │  │
│  │ todos/toggleTodo                                       │  │
│  └────────────────────────────────────────────────────────┘  │
│  Each entry identifies the action that produced it.          │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Middleware importdevtools from zustand/middleware
Browser extensionRedux DevTools
Action nameThird argument to set
Store namename option in middleware config
Production disableenabled: process.env.NODE_ENV === 'development'
Anonymous fallbackanonymousActionType option
Middleware orderdevtools outermost
Time-travelSlider in the DevTools extension

Key takeaways:

  • The devtools middleware connects the store to the Redux DevTools extension. Every set call appears in the timeline with the action name, the previous state, the new state, and the diff .
  • The action name is the third argument to set. Without it, the timeline entry is labeled with a generic name, and the developer cannot identify which interaction produced which change. set({ count: 1 }, false, 'counter/increment') labels the entry clearly.
  • Disable the middleware in production. The enabled option should be set to process.env.NODE_ENV === 'development'. The middleware serializes the state for every change, and that overhead is not appropriate for production builds .
  • Place devtools outermost. The middleware order is devtools(persist(immer(...))). The outer middleware see the final state, and the inner middleware transform it. Reversing the order causes the outer middleware to receive the draft, which is not serializable .
  • The name option identifies the store. When multiple stores are connected, the name appears in the extension’s dropdown. A descriptive name like TodoStore or AuthStore makes it easy to find the store .
  • The extension must be installed. The middleware connects to the extension through the browser’s DevTools protocol. Without the extension, the middleware runs but there is no UI to display the entries.

Remember: The devtools middleware is the debugging layer. It records every state change, labels it with the action name, and sends it to the browser extension. The action name is what makes the timeline readable, and the enabled flag is what keeps the overhead out of production. The middleware order determines what each layer sees, and the devtools layer must be outermost. Install the extension, wrap the store, name the actions, disable in production, and the store becomes inspectable and time-travelable.


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!