Zustand Persist Middleware: Partial State Slices and Merge Boundaries
Persist only selected Zustand state slices to localStorage and control how returning values merge with live state.
20 Mar 2026, 11:31 UTC

Problem: Persisting the Entire Store Creates Bloat and Schema Conflicts
When a Zustand store grows beyond a few values, saving the whole state object to localStorage on every change forces the browser to serialize unrelated data. Worse, if the store shape changes between deployments, rehydrating a stale object can overwrite current defaults or leave missing fields undefined.
Useful takeaway: configure persist to save only the slices you need, and provide a merge function that decides how returning values combine with the live store. This keeps storage small and makes schema evolution predictable.
Requirements
- Persist only selected state fields, not the entire store.
- Control how persisted values merge with in-memory state on hydration.
- Keep actions and derived state outside the persisted boundary.
Smallest Suitable Design
The persist middleware wraps a Zustand store and serializes state to a storage engine. The partialize option chooses which slices to write. The merge option chooses how to combine the persisted object with the state produced by the store's initial initializer.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface Store {
theme: string;
draft: string;
submitted: string[];
submit: () => void;
}
const useFormStore = create()(
persist(
(set, get) => ({
theme: 'light',
draft: '',
submitted: [],
submit: () => {
const { draft, submitted } = get();
set({ submitted: [...submitted, draft], draft: '' });
},
})),
{
name: 'form-storage',
partialize: (state) => ({
theme: state.theme,
draft: state.draft,
}),
merge: (persistedState, currentState) => ({
...currentState,
...persistedState,
submitted: currentState.submitted,
}),
}
)
);
In this design, submitted lives only in memory. theme and draft are written to localStorage. On page reload, merge receives the persisted object and the freshly initialized store, then returns the combined state. The spread order ensures current defaults win for keys missing from storage, while persisted values overwrite them when present.
Trust and Data Boundaries
The storage boundary is the keyed slot in localStorage. Anything written there is visible to other tabs and extensions, so do not persist secrets or tokens. The Immer proxy boundary, if you also use the immer middleware, ends before serialization: persist receives the final immutable state, not a proxy.
Middleware order matters. persist wraps the store creator and intercepts set after the store produces the new state. If you add logging or analytics middleware inside the persist wrapper, those side effects run on every persisted write, which can obscure which state change triggered the storage update.
Operational Checks
- Verify persisted keys: Open DevTools > Application > Local Storage and confirm only expected keys appear under the persist name.
- Check merge behavior: Reload the page, change a non-persisted field in memory, then refresh. The field should return to its initial value, not the persisted value.
- Inspect serialization errors: Wrap store creation in a try/catch during development. Non-serializable values such as Date instances or functions in persisted slices will throw or produce unexpected JSON.
Failure Modes and Conditions That Change the Design
- Stale partial state: If a persisted slice contains a value that is no longer valid in the new code, merge may apply it directly. Add a migration step inside merge to reset or transform known obsolete fields.
- Merge overwrites live updates: If merge spreads persisted state after current state, a tab that writes to localStorage can overwrite changes made in another tab after reload. Consider a last-write-wins strategy or a version field.
- Non-serializable values: Storing class instances, DOM nodes, or functions in a persisted slice breaks JSON.stringify and can silently corrupt the stored payload.
- Storage quota or private mode: localStorage may be unavailable or quota-limited in private browsing. The persist middleware can throw on write. Wrap initialization in a fallback that degrades to an in-memory store.
Concrete Diagnostic: Confirming Slice Isolation
Run this check in the browser console after mounting a component that uses the store:
import { useFormStore } from './store';
const state = useFormStore.getState();
console.log('Persisted keys expected:', ['theme', 'draft']);
console.log('In-memory only:', state.submitted);
Then open Application > Local Storage. The value under form-storage should contain theme and draft, but not submitted. If submitted appears, partialize is not filtering correctly.
Limitations
- Persist works with synchronous storage. Async storage in React Native requires a custom storage object passed to the middleware.
- Large persisted objects block the main thread during serialization on every state change. Keep slices small.
- The merge function runs on every hydration, not on every set. It does not mediate conflicts between live tabs writing to the same key.
Practical Result Check
- Deploy the store with partialize and merge as shown.
- Change draft in the UI, refresh the page, and confirm the draft reappears.
- Change theme directly in localStorage, refresh, and confirm the UI reads the edited value.
- Submit a form entry, refresh, and confirm submitted is empty because it was excluded from persistence.
When to Change the Design
- If you need encryption, signed cookies, or server-side hydration, replace localStorage with a custom storage object in the persist options.
- If persisted state must sync across tabs in real time, add a broadcast channel listener outside Zustand or switch to a sync-capable store.
- If the store shape changes frequently, maintain a version number in the persisted object and run migrations inside merge before returning combined state.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.