How to Debug and Log Zustand State Changes in Development

Zustand provides built-in debugging through Redux DevTools integration and manual subscription logging, allowing you to track every state mutation in development.

The pmndrs/zustand library ships with minimal yet powerful debugging capabilities that leverage its publish-subscribe architecture. Because the core store implementation exposes direct subscription methods and middleware hooks, you can inspect state changes using browser DevTools extensions or simple console logging. This guide covers the exact implementation details found in the source code to help you set up effective debugging workflows.

Understanding the Core Store Architecture

Zustand’s state management revolves around a tiny publish-subscribe system implemented in src/vanilla.ts. The createStore function initializes a store with internal setState and subscribe methods that form the foundation of all debugging capabilities.

When setState is called, it mutates the internal state object and immediately notifies every registered listener via listeners.forEach (lines 66-80). The subscribe method (lines 88-92) simply registers a callback to this listener set and returns an unsubscribe function. This design means every state change is observable by attaching a listener to the store instance.

Debugging with Redux DevTools

The devtools middleware in src/middleware/devtools.ts provides first-class integration with the Redux DevTools browser extension. It wraps the store’s setState method to intercept every mutation and forward it as a labeled action to the DevTools UI.

Configuring the DevTools Middleware

The middleware establishes a connection to the Redux DevTools extension and replaces the native setState with a wrapper that builds action descriptions. According to the source code (lines 16-33 and 90-106), the wrapper constructs an action object with a type property and sends it via connection?.send(action, get()).

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

const useStore = create(
  devtools(
    (set) => ({
      count: 0,
      inc: () => set((s) => ({ count: s.count + 1 }), undefined, 'inc')
    }),
    {
      name: 'CounterStore',
      anonymousActionType: 'unknown',
      enabled: true
    }
  )
)

The enabled flag defaults to true in non-production builds (lines 98-103), automatically activating debugging only during development.

Naming Actions for Clear Logs

To make DevTools entries meaningful, pass a string as the third argument to set():

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

If you omit the name, the middleware falls back to anonymousActionType or attempts to infer the caller name from the stack trace. You can also pass a full action object for richer logging:

set(
  (state) => ({ count: state.count + 1 }),
  undefined,
  { type: 'counter/inc', payload: 1 }
)

Logging State Changes to the Console

For quick debugging without browser extensions, attach listeners directly to the store using the native subscribe method.

Basic Subscription Logging

The subscribe method receives the new state (and optionally the previous state) whenever setState executes:

const useStore = create((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 }))
}))

// Log every full state change
useStore.subscribe((newState) => console.log('state →', newState))

Because listeners receive the complete state object, you can implement custom diffing logic to highlight specific changes between updates.

Selective Logging with subscribeWithSelector

When you only need to watch a specific property, importing subscribeWithSelector from src/middleware/subscribeWithSelector.ts provides an optimized solution. This middleware wraps the original api.subscribe to accept a selector function and an optional equality comparator.

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

const useStore = create(
  subscribeWithSelector((set) => ({
    count: 0,
    text: '',
    inc: () => set((s) => ({ count: s.count + 1 }))
  }))
)

// Log only when `count` changes
useStore.subscribe(
  (state) => state.count,
  (newCount, oldCount) => console.log('count changed:', oldCount, '→', newCount)
)

The selector is evaluated on every state update (lines 54-62), and the listener fires only when equalityFn (defaulting to Object.is) detects a difference. This prevents noisy logs when unrelated properties mutate.

Creating a Custom Logger Middleware

For reusable debugging logic that works without DevTools, compose a custom middleware that wraps the set function:

import { create, StateCreator } from 'zustand'

const logger = <T>(config: StateCreator<T>) =>
  (set, get, api) => {
    const loggingSet: typeof set = (partial, replace, name) => {
      console.log('[log] action:', name ?? 'anonymous', 'Δ', partial)
      set(partial, replace, name)
    }
    return config(loggingSet, get, api)
  }

const useStore = create(
  logger((set) => ({
    count: 0,
    inc: () => set((s) => ({ count: s.count + 1 }), undefined, 'inc')
  }))
)

This pattern intercepts every set call to print the action name and partial state update before delegating to the original setter.

Combining DevTools and Console Logging

A robust development setup typically combines both approaches: DevTools for time-travel debugging and console subscriptions for ad-hoc monitoring.

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

const useStore = create(
  subscribeWithSelector(
    devtools(
      (set) => ({
        count: 0,
        user: null,
        inc: () => set((s) => ({ count: s.count + 1 }), undefined, 'inc')
      }),
      { name: 'AppStore', anonymousActionType: '⚡' }
    )
  )
)

// Console-only monitoring for specific slices
useStore.subscribe(
  (s) => s.count,
  (c) => console.log('[dev] count updated:', c)
)

All state changes appear in the Redux DevTools timeline with full action descriptions, while the console subscription prints concise updates only when count actually changes.

Summary

  • Core architecture: The store in src/vanilla.ts notifies all listeners synchronously after every setState call, making all changes observable.
  • Redux DevTools: The devtools middleware in src/middleware/devtools.ts wraps setState to send labeled actions to the browser extension, supporting custom action names via the third set parameter.
  • Console logging: Direct store.subscribe() calls provide immediate state inspection without external dependencies.
  • Selective logging: subscribeWithSelector (from src/middleware/subscribeWithSelector.ts) filters updates using selector functions and Object.is equality checks.
  • Custom middleware: Wrapping the set function creates reusable loggers that capture action names and partial updates.

Frequently Asked Questions

How do I enable Redux DevTools in a Zustand store?

Import the devtools middleware from zustand/middleware and wrap your store creator. Pass a configuration object with name to identify your store in the DevTools panel and set enabled: true to ensure it activates in development. Every call to set() will then appear as a traceable action in the Redux DevTools extension.

Can I log only specific properties instead of the entire state?

Yes, apply the subscribeWithSelector middleware to your store, then call subscribe with two arguments: a selector function that extracts the property you want to watch, and a listener that receives the new and previous values. The listener fires only when the selected slice changes according to the default Object.is comparison.

What is the difference between subscribe and subscribeWithSelector?

The base subscribe method (from src/vanilla.ts) invokes its listener on every state change with the full state object. subscribeWithSelector (from src/middleware/subscribeWithSelector.ts) extends this to accept a selector and equality function, only calling your listener when the selected value actually differs, which improves performance and reduces log noise.

How can I name my actions for better DevTools debugging?

Pass a string as the third argument to the set function: set(updater, false, 'myActionName'). If you use the devtools middleware, this string becomes the action type in the DevTools UI. Without an explicit name, the middleware falls back to the anonymousActionType configuration option or marks the action as "anonymous".

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →