Redux DevTools Integration
Recipe
Wrap your store with the devtools middleware to connect to the Redux DevTools browser extension. Inspect state, track actions, and use time-travel debugging.
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface AppStore {
count: number;
increment: () => void;
decrement: () => void;
}
export const useAppStore = create<AppStore>()(
devtools(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 }), false, "increment"),
decrement: () => set((s) => ({ count: s.count - 1 }), false, "decrement"),
}),
{ name: "AppStore" }
)
);Working Example
// stores/order-store.ts
import { create } from "zustand";
import { devtools } from "zustand/middleware";
interface OrderItem {
id: string;
name: string;
quantity: number;
price: number;
}
interface OrderStore {
items: OrderItem[];
status: "idle" | "processing" | "completed" | "error";
discount: number;
addItem: (item: Omit<OrderItem, "quantity">) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
applyDiscount: (percent: number) => void;
submitOrder: () => Promise<void>;
resetOrder: () => void;
}
export const useOrderStore = create<OrderStore>()(
devtools(
(set, get) => ({
items: [],
status: "idle",
discount: 0,
addItem: (item) =>
set(
(s) => {
const existing = s.items.find((i) => i.id === item.id);
if (existing) {
return {
items: s.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
),
};
}
return { items: [...s.items, { ...item, quantity: 1 }] };
},
false,
{ type: "order/addItem", item } // Named action with payload
),
removeItem: (id) =>
set(
(s) => ({ items: s.items.filter((i) => i.id !== id) }),
false,
{ type: "order/removeItem", id }
),
updateQuantity: (id, quantity) =>
set(
(s) => ({
items: s.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
}),
false,
{ type: "order/updateQuantity", id, quantity }
),
applyDiscount: (percent) =>
set(
{ discount: Math.min(100, Math.max(0, percent)) },
false,
{ type: "order/applyDiscount", percent }
),
submitOrder: async () => {
set({ status: "processing" }, false, "order/submitOrder/pending");
try {
const { items, discount } = get();
await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items, discount }),
});
set(
{ status: "completed", items: [], discount: 0 },
false,
"order/submitOrder/fulfilled"
);
} catch {
set({ status: "error" }, false, "order/submitOrder/rejected");
}
},
resetOrder: () =>
set(
{ items: [], status: "idle", discount: 0 },
false,
"order/reset"
),
}),
{
name: "OrderStore",
enabled: process.env.NODE_ENV === "development",
}
)
);// components/order-form.tsx
"use client";
import { useOrderStore } from "@/stores/order-store";
export function OrderForm() {
const items = useOrderStore((s) => s.items);
const status = useOrderStore((s) => s.status);
const discount = useOrderStore((s) => s.discount);
const addItem = useOrderStore((s) => s.addItem);
const removeItem = useOrderStore((s) => s.removeItem);
const updateQuantity = useOrderStore((s) => s.updateQuantity);
const applyDiscount = useOrderStore((s) => s.applyDiscount);
const submitOrder = useOrderStore((s) => s.submitOrder);
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const total = subtotal * (1 - discount / 100);
return (
<div>
<h2>Order ({status})</h2>
<button
onClick={() => addItem({ id: "p1", name: "Widget", price: 9.99 })}
>
Add Widget
</button>
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name} x{item.quantity} = ${(item.price * item.quantity).toFixed(2)}
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
<div>
<label>
Discount %:
<input
type="number"
value={discount}
onChange={(e) => applyDiscount(Number(e.target.value))}
/>
</label>
</div>
<p>Total: ${total.toFixed(2)}</p>
<button onClick={submitOrder} disabled={status === "processing"}>
{status === "processing" ? "Submitting..." : "Submit Order"}
</button>
</div>
);
}Deep Dive
How It Works
- The
devtoolsmiddleware intercepts everysetcall and sends the state diff plus an action name to the Redux DevTools extension. - The third argument to
set(state, replace, actionName)names the action in DevTools. Without it, actions show as "anonymous." - Action names can be strings (
"increment") or objects ({ type: "order/addItem", payload }) for richer debugging info. - The
nameoption in devtools config sets the store name shown in the DevTools instance selector. - Time-travel debugging lets you jump to any previous state. Zustand replays state changes through
setState. - The
enabledoption controls whether DevTools connects. Set it tofalsein production to avoid overhead.
Variations
Multiple stores in DevTools:
// Each store gets its own DevTools instance
const useAuthStore = create<AuthState>()(
devtools((set) => ({ ... }), { name: "AuthStore" })
);
const useCartStore = create<CartState>()(
devtools((set) => ({ ... }), { name: "CartStore" })
);
// Both appear as separate instances in Redux DevToolsConnecting to a remote DevTools server:
devtools(storeCreator, {
name: "RemoteStore",
// Connect to remote Redux DevTools instance
// Useful for debugging mobile or embedded apps
});Trace option for stack traces:
devtools(storeCreator, {
name: "Store",
trace: true, // Show stack trace for each action
traceLimit: 25, // Max stack frames to show
});With other middleware:
// devtools should always be the outermost middleware
const useStore = create<State>()(
devtools(
persist(
immer((set) => ({ ... })),
{ name: "persist-key" }
),
{ name: "DevToolsName" }
)
);TypeScript Notes
- The
devtoolsmiddleware does not change the store type. All types are preserved. - The action name parameter on
setis typed asstring | { type: string; [key: string]: unknown }.
// Fully typed set with action name
set(
(state) => ({ count: state.count + 1 }), // State updater
false, // replace flag
{ type: "counter/increment", by: 1 } // Action for DevTools
);Gotchas
- If
devtoolsis not the outermost middleware, the DevTools may not capture all state changes correctly. Always wrap everything else insidedevtools. - The third argument to
set(action name) only works whendevtoolsmiddleware is active. Without it, the third argument is silently ignored. - DevTools adds runtime overhead: serializing state on every update. Disable with
enabled: falsein production. - Time-travel debugging replaces the entire state. If your store has side effects in subscriptions, time-traveling may trigger them unexpectedly.
- Very large state trees can slow down DevTools. Use the
serializeoption to filter or limit what is sent. - The Redux DevTools browser extension must be installed for devtools middleware to have any effect. Without it, the middleware is a no-op.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Redux DevTools middleware | Time-travel, visual state inspection | Requires browser extension, production overhead |
| Console logging middleware | No dependencies, simple | No visual UI, no time-travel |
| React DevTools | Built-in, shows component state | Cannot inspect Zustand stores directly |
| Custom debug panel | Tailored to your app | Must build and maintain it |
FAQs
What does the devtools middleware do?
- It connects your Zustand store to the Redux DevTools browser extension.
- You can inspect state, track named actions, and use time-travel debugging.
How do you name actions in DevTools?
set(
(s) => ({ count: s.count + 1 }),
false,
"increment" // Action name shown in DevTools
);- Pass the action name as the third argument to
set. - You can also pass an object:
{ type: "order/addItem", payload }.
What does the name option in the devtools config do?
- It sets the store name shown in the Redux DevTools instance selector.
- When you have multiple stores, each appears as a separate named instance.
How does time-travel debugging work with Zustand?
- DevTools records each state change as a snapshot.
- Jumping to a previous state replaces the entire store via
setState. - All subscribed components re-render with the time-traveled state.
How do you disable devtools in production?
devtools(storeCreator, {
name: "AppStore",
enabled: process.env.NODE_ENV === "development",
});Gotcha: Where should devtools go in the middleware stack?
- Always outermost. If
devtoolsis not the outermost middleware, it may not capture all state changes correctly. - Example:
devtools(persist(immer((set) => ({ ... })))).
Gotcha: What happens if the third argument to set is used without devtools middleware?
- It is silently ignored. The action name parameter only has effect when
devtoolsmiddleware is active.
Does DevTools add runtime overhead?
- Yes. It serializes state on every update to send to the extension.
- Very large state trees can slow down DevTools. Use the
serializeoption to filter what is sent. - Always disable in production with
enabled: false.
Can you enable stack traces for actions in DevTools?
devtools(storeCreator, {
name: "Store",
trace: true,
traceLimit: 25,
});- The
traceoption adds a stack trace to each action in DevTools.
What is the TypeScript type for the action name parameter in set?
- It is typed as
string | { type: string; [key: string]: unknown }. - The
devtoolsmiddleware does not change the store's type signature.
set(
(state) => ({ count: state.count + 1 }),
false,
{ type: "counter/increment", by: 1 }
);Does the Redux DevTools browser extension need to be installed for the middleware to work?
- Yes. Without the extension, the devtools middleware is a no-op (does nothing).
- It does not cause errors if the extension is missing.