Combining persist and devtools without bloating storage or confusing DevTools
A Zustand store stays transient and hard to debug without middleware. persist and devtools solve exactly those two problems, once you know how to combine them and scope them with partialize.
Table of Contents
- 1. What Zustand Middlewares Actually Are
- 2. persist Basics
- 3. partialize for Selective Persistence
- 4. devtools and the Redux DevTools Extension
- 5. Combining Multiple Middlewares
- 6. Versioning and Migrating Persisted State
- 7. Using Custom Storage Adapters
- 8. Hydration Timing with Server Side Rendering
- 9. Common Mistakes with Zustand Middleware
- 10. Summary
- 11. FAQ
1. What Zustand Middlewares Actually Are
A Zustand middleware is a function that wraps the actual store creator and injects additional behavior without changing the domain store logic itself. persist adds saving and restoring state, devtools adds a connection to the Redux DevTools browser extension, both work transparently in the background of the actual state updates.
Compared to Redux, where middleware is wired in through a dedicated store enhancer mechanism with comparatively more boilerplate, a Zustand middleware is simply a higher order function wrapped around create(). That makes middlewares minimally invasive, easy to combine, and easy to remove again without touching the rest of the store.
2. persist Basics
persist(config, { name, storage }) automatically saves the store's state to localStorage, or alternatively to sessionStorage or any custom storage adapter. The name option determines the key the serialized state is stored under, and it should be unique across the project to avoid colliding with other stores.
On app startup, persist automatically reads from storage and rehydrates the store before the first component even renders. A custom useEffect that manually reads from localStorage on mount and fills the store is no longer needed, that entire flow is already encapsulated in the middleware.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{ name: 'settings-storage' }
)
);
3. partialize for Selective Persistence
partialize(state) lets you persist only part of the state, say durable user settings instead of transient UI flags like isModalOpen or activeTab. Without partialize, the entire store lands in storage by default, including values that should reset to their initial value on the next load anyway.
In practice, partialize is usually defined as a function returning a new object with only the desired keys. In TypeScript, the return type can be secured with Partial, so an accidentally forgotten or misspelled field shows up at compile time instead of silently missing from storage at runtime.
const useAppStore = create(
persist(
(set) => ({
theme: 'light',
language: 'en',
isModalOpen: false,
activeTab: 'overview',
setTheme: (theme) => set({ theme }),
}),
{
name: 'app-storage',
// Only persist durable settings,
// transient UI flags are left out
partialize: (state) => ({
theme: state.theme,
language: state.language,
}),
}
)
);
4. devtools and the Redux DevTools Extension
devtools(config, { name }) connects the store to the Redux DevTools browser extension. Every state change shows up there as its own entry on the timeline, including the ability to jump back to an earlier state snapshot via time travel debugging and inspect the app in exactly that state.
For meaningful timeline entries, set calls should be given a second and third argument, for example set(newState, false, 'todos/add'). Without that name, DevTools only shows generic, anonymous actions, which makes tracing the cause of a bug in a larger store considerably harder.
import { devtools } from 'zustand/middleware';
const useTodoStore = create(
devtools(
(set) => ({
todos: [],
addTodo: (text) =>
set(
(state) => ({ todos: [...state.todos, { id: crypto.randomUUID(), text }] }),
false,
'todos/add'
),
}),
{ name: 'TodoStore' }
)
);
5. Combining Multiple Middlewares
Middlewares are applied by nesting them, a common pattern is devtools(persist(immer(storeCreator))). The order of this nesting isn't arbitrary, it affects both what shows up in DevTools and how state gets transformed before persist saves it.
An awkward order, say persist outside of devtools instead of inside, can make DevTools display the already serialized or rehydrated state incorrectly or with wrong action names. In TypeScript, type inference also gets noticeably more complex with several nested middlewares and benefits from explicit StateCreator type annotations on the respective store slices.
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
const useStore = create(
devtools(
persist(
immer((set) => ({
todos: [],
addTodo: (text) =>
set((state) => {
state.todos.push({ id: crypto.randomUUID(), text });
}),
})),
{ name: 'todo-storage' }
),
{ name: 'TodoStore' }
)
);
6. Versioning and Migrating Persisted State
The version and migrate options in persist let you carry stored state from older app versions over into the current schema on load, instead of discarding it or risking a runtime error when expected fields are missing.
migrate(persistedState, version) receives the old state together with its stored version number and returns the transformed, current state. This matters especially when a field was renamed, a new required field was introduced, or the structure of a nested object changed, so users with older, locally stored state don't end up with a broken store on their next visit.
const useSettingsStore = create(
persist(
(set) => ({
themeMode: 'light',
setThemeMode: (themeMode) => set({ themeMode }),
}),
{
name: 'settings-storage',
version: 2,
migrate: (persistedState, version) => {
if (version === 1) {
// Old field 'theme' was renamed to 'themeMode'
return { themeMode: persistedState.theme ?? 'light' };
}
return persistedState;
},
}
)
);
7. Using Custom Storage Adapters
The storage option accepts any adapter that implements the getItem, setItem, and removeItem methods. That lets you use AsyncStorage in React Native instead of localStorage, for example, or an encrypting wrapper around localStorage when sensitive data needs to be stored locally.
createJSONStorage(() => customStorageEngine) encapsulates JSON serialization, so a custom adapter only needs to serve the raw storage interface, Zustand handles the conversion between JavaScript object and string automatically. That keeps custom code limited to pure storage logic without having to worry about serialization details.
import { createJSONStorage, persist } from 'zustand/middleware';
const encryptedStorage = {
getItem: (name) => {
const raw = localStorage.getItem(name);
return raw ? decrypt(raw) : null;
},
setItem: (name, value) => localStorage.setItem(name, encrypt(value)),
removeItem: (name) => localStorage.removeItem(name),
};
const useSecureStore = create(
persist(
(set) => ({ token: null, setToken: (token) => set({ token }) }),
{ name: 'secure-storage', storage: createJSONStorage(() => encryptedStorage) }
)
);
8. Hydration Timing with Server Side Rendering
With server side rendering, say with Next.js, no localStorage exists on the first server render. persist therefore initially marks the store as not hydrated, and the actual rehydration moment on the client can be captured via the onRehydrateStorage callback option.
A common solution is an extra hasHydrated flag in the store, only set to true once rehydration succeeds. Components can render a placeholder or loading state until then, instead of showing a brief server client mismatch that would otherwise cause values to visibly flicker.
const useAppStore = create(
persist(
(set) => ({
hasHydrated: false,
theme: 'light',
setHasHydrated: (value) => set({ hasHydrated: value }),
}),
{
name: 'app-storage',
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
}
)
);
function ThemeSwitcher() {
const hasHydrated = useAppStore((s) => s.hasHydrated);
if (!hasHydrated) return <Skeleton />;
return <ThemeToggle />;
}
9. Common Mistakes with Zustand Middleware
The most common mistake is missing partialize, which lets the entire store, including transient UI flags, land in localStorage. That unnecessarily bloats storage and can restore unexpected UI states on the next load, for example a modal that's suddenly open again after a reload, even though the user had long since closed it.
A second common mistake is an unsuitable middleware order or forgetting versioning on schema changes. Forget to bump version with a matching migrate function, and old, already persisted state can cause runtime errors after a deployment, simply because fields the current code expects are missing from the stored object.
| Middleware | Purpose | Typical Pitfall | Combinable With |
|---|---|---|---|
| persist | Preserve state across page reloads in localStorage or a custom storage | Missing partialize bloats storage with transient UI flags | devtools, immer, subscribeWithSelector |
| devtools | Connection to Redux DevTools including time travel debugging | set calls without action names show up as generic, unreadable entries | persist, immer |
| immer | Mutable looking syntax for nested state updates | Forgotten return statement on non-draft returns in complex updates | persist, devtools |
| subscribeWithSelector | Targeted subscription to individual state slices outside React | Missing equalityFn causes unnecessarily frequent callback calls | persist, devtools, immer |
Mironsoft
React architecture, performance, and Magento frontend integration
React frontends that stay fast instead of slowing down with every feature?
We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.
Performance Audit
Systematically measuring and fixing re-renders, bundle size, and load times.
State Architecture
Cleanly separating context, client state, and server state instead of mixing everything.
Magento Integration
Building robust, type-safe GraphQL or REST integration with Magento.
10. Summary
Zustand Middleware persist and devtools: The Essentials at a Glance
persist
Automatically saves and rehydrates the store from localStorage or any custom storage adapter.
partialize
Limits persistence to durable fields, transient UI flags stay out of storage.
devtools
Connects the store to Redux DevTools, named actions make the timeline meaningful.
Middleware Order
Nesting like devtools(persist(immer(...))) directly affects visibility and serialization.