Zustand Middleware: Using persist and devtools in Practice
AI generated
{ }
React 19 · Zustand · State Management
Using Zustand Middleware in Practice
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.

13 min read Zustand · persist · devtools State Management

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.

11. FAQ: Zustand Middleware persist and devtools: The Essentials at a Glance

1What exactly does a Zustand middleware do?
A middleware wraps the store creator as a higher order function and injects additional behavior like persistence or DevTools connectivity, without changing the domain store logic itself.
2Where does persist store state by default?
By default in localStorage under the key specified in the name option. Alternatively, the storage option lets you use sessionStorage or a custom adapter.
3Why should I use partialize instead of persisting the entire store?
Without partialize, the entire store lands in storage, including transient UI flags that should reset on the next load anyway. partialize limits persistence to actually durable fields and keeps storage small.
4How do I make Redux DevTools entries meaningful?
By giving set calls a second and third argument, for example set(newState, false, 'todos/add'). Without that name, DevTools only shows generic, anonymous actions.
5Does the order matter when nesting multiple middlewares?
Yes. The order affects what shows up in DevTools and how state gets transformed before saving. An awkward order can make DevTools display the already serialized state incorrectly.
6What are version and migrate in persist for?
They let you carry stored state from older app versions over into the current schema on load. migrate receives the old state and its version number and returns the transformed, current state.
7Can I use persist with React Native or a custom storage?
Yes, the storage option accepts any adapter implementing getItem, setItem, and removeItem, such as AsyncStorage in React Native or an encrypting wrapper around localStorage.
8How do I handle server side rendering when using persist?
Via an extra hasHydrated flag, only set to true once rehydration succeeds, inside the onRehydrateStorage callback. Components can render a placeholder until then instead of a server client mismatch.
9What happens if I bump the version without a migrate function?
Old, already persisted state can then cause runtime errors after a deployment, simply because fields expected by the current code are missing from the stored object. A matching migrate function prevents that.
10Can I use multiple middlewares at once, like persist and immer together?
Yes, that's a common pattern, for example devtools(persist(immer(storeCreator))). The important part is choosing the nesting order deliberately, since it affects DevTools visibility and serialization.