Zustand 09 ⚛️ Middleware DevTools — Integrating Redux DevTools for debugging
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
| Option | Type | Purpose |
|---|---|---|
name | String | Store name in DevTools dropdown |
enabled | Boolean | Enable/disable middleware |
anonymousActionType | String | Fallback label for unnamed actions |
set Arguments with DevTools
| Argument | Type | Purpose |
|---|---|---|
| 1 | Object or function | Partial state or updater |
| 2 | Boolean | Replace flag (usually false) |
| 3 | String | Action name for DevTools |
Middleware Order
| Order | Correct | Wrong |
|---|---|---|
| Outermost | devtools | immer |
| Middle | persist | persist |
| Innermost | immer | devtools |
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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| DevTools shows nothing | Extension not installed or disabled | Install Redux DevTools browser extension |
| Timeline entries are anonymous | No action name on set | Add the third argument |
Actions removed after reset | Used replace: true | Use false for the replace flag |
| Production overhead | enabled not conditional | enabled: process.env.NODE_ENV === 'development' |
| Timeline shows draft | immer placed outside devtools | Place devtools outermost |
| State is not serializable | A function or class instance is in the state | Keep 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
| Item | Value |
|---|---|
| Middleware import | devtools from zustand/middleware |
| Browser extension | Redux DevTools |
| Action name | Third argument to set |
| Store name | name option in middleware config |
| Production disable | enabled: process.env.NODE_ENV === 'development' |
| Anonymous fallback | anonymousActionType option |
| Middleware order | devtools outermost |
| Time-travel | Slider in the DevTools extension |
Key takeaways:
- The
devtoolsmiddleware connects the store to the Redux DevTools extension. Everysetcall 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
enabledoption should be set toprocess.env.NODE_ENV === 'development'. The middleware serializes the state for every change, and that overhead is not appropriate for production builds . - Place
devtoolsoutermost. The middleware order isdevtools(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
nameoption identifies the store. When multiple stores are connected, the name appears in the extension’s dropdown. A descriptive name likeTodoStoreorAuthStoremakes 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.
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!