How to Use `partialize` to Select What to Save and `merge` to Safely Restore Nested Objects in Zustand's persist Middleware
If you've worked with state management libraries long enough, you've probably run into this situation at least once. Login state needs to survive a page refresh, but sidebar open/closed state also ends up in localStorage, so when you close and reopen the page, the UI gets restored in a weird state. At first I thought, "Why not just save the entire store?"—but in practice, this difference leads to some pretty significant UX bugs.
This post is aimed at people who have basic experience with Zustand's persist middleware and are reasonably familiar with the slice pattern. It also includes Next.js App Router examples, so keep that in mind. By the end of this post, you'll be able to independently configure three steps: writing a partialize filter → choosing a merge strategy → handling SSR hydration.
The persist middleware provides two options to solve this problem. Use partialize to choose which state to save, and merge to control how the saved state is combined with the current state. It sounds simple, but if you don't properly understand how these two options work, you'll end up with silent bugs after deployment that are hard to diagnose.
Core Concepts
partialize — A Filter for Choosing What to Save
partialize is a function that runs before the store state is written to storage. Only the object it returns gets serialized and saved.
persist(storeCreator, {
name: 'app-store',
partialize: (state) => ({
user: state.user,
theme: state.theme,
// UI state like sidebarOpen, modalVisible, cache is excluded here
}),
})One thing worth knowing: partialize filters what gets saved, but it doesn't change the subscription itself. That means even when sidebarOpen changes, the serialization logic still runs—it's just that sidebarOpen is omitted from the output. There's no significant performance impact, but if you have a slice whose state changes at high frequency (e.g., drag coordinates), it's good to be aware of this.
The role of
partialize: It's a pure function that returns a subset of the total state to be written to storage. State not included in the return value exists only in memory and is never written to storage.
merge — How to Combine Saved State with Current State
When the page is refreshed, Zustand loads the saved state from storage and merges it with the current initial state. It's important to know that this merge behavior is shallow merge by default.
// Default behavior — this is what happens internally
{ ...currentState, ...persistedState }For a flat state structure, this works fine. But with nested objects, things get complicated.
// Current initial state (fields newly added in code)
currentState.settings = {
theme: { color: 'blue', font: 'sans' },
notifications: { email: true, push: true } // push was added in this deployment
}
// State saved in storage (previous version, no push field)
persistedState.settings = {
theme: { color: 'red' },
notifications: { email: false }
}
// Result of shallow merge: the settings object itself is replaced
// → notifications.push disappears!
{ ...currentState, settings: persistedState.settings }Honestly, you'll probably fall into this trap at least once. When you first deploy the app, there's no saved data so everything looks fine—then new fields appear as undefined only for existing users, and it takes a long time to figure out why.
import { merge as deepMerge } from 'lodash-es'
persist(storeCreator, {
name: 'settings-store',
merge: (persistedState, currentState) =>
deepMerge({}, currentState, persistedState),
// Start with currentState defaults, then overwrite with persistedState values
// New fields preserve their defaults from currentState
})Shallow merge vs. deep merge: Shallow merge only combines top-level keys. Merging
{ a: { x: 1 } }and{ a: { y: 2 } }shallowly gives{ a: { y: 2 } }—xdisappears. Deep merge recursively combines nested objects to produce{ a: { x: 1, y: 2 } }.
One distinction worth keeping in mind: the merge option defines how to merge when restoring from storage. The deepMerge inside the updateSettings action in Example 2 below is separate—it's about preserving existing nested values when updating state via an action. They use the same function, but operate at different layers.
Practical Application
Example 1: Selective Persistence Per Slice
The most basic pattern: separate slices by domain and explicitly specify which state to save with partialize. Adding the StateCreator type ensures type safety across slices.
import type { StateCreator } from 'zustand'
// userSlice.ts
interface UserSlice {
user: User | null
token: string | null
setUser: (user: User) => void
clearUser: () => void
}
export const createUserSlice: StateCreator<AppState, [], [], UserSlice> = (set) => ({
user: null,
token: null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null, token: null }),
})
// uiSlice.ts
interface UiSlice {
sidebarOpen: boolean
theme: 'light' | 'dark'
activeModal: string | null
setSidebarOpen: (open: boolean) => void
setTheme: (theme: 'light' | 'dark') => void
}
export const createUiSlice: StateCreator<AppState, [], [], UiSlice> = (set) => ({
sidebarOpen: false,
theme: 'light',
activeModal: null,
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setTheme: (theme) => set({ theme }),
})
// store.ts
type AppState = UserSlice & UiSlice
export const useStore = create<AppState>()(
persist(
(...a) => ({
...createUserSlice(...a),
...createUiSlice(...a),
}),
{
name: 'app-store',
partialize: (state) => ({
user: state.user,
token: state.token,
theme: state.theme,
// sidebarOpen, activeModal excluded — volatile UI state
}),
}
)
)...a is the (set, get, api) tuple — the idiomatic way to pass the Zustand store API to slice creators in the slice pattern. If you're new to the slice pattern, it's worth reading the Zustand official slice guide first.
| State Key | Saved | Reason |
|---|---|---|
user |
✅ | Needs to persist login state |
token |
✅ | Auth token must persist |
theme |
✅ | Preserve user preferences |
sidebarOpen |
❌ | Should start in initial state on every visit |
activeModal |
❌ | Modals should naturally start closed |
Example 2: Safe Merging in a Settings Slice with Nested Objects
When app settings have a nested structure, deploying without merge can cause newly added settings fields to appear as undefined for existing users. You can prevent this with lodash or a lightweight deepmerge library.
import { merge as deepMerge } from 'lodash-es'
// If you want something lighter without lodash: import deepMerge from 'deepmerge'
export const useSettingsStore = create(
persist(
(set) => ({
settings: {
theme: { color: 'blue', font: 'system-ui', fontSize: 14 },
notifications: { email: true, push: true, slack: false },
editor: { tabSize: 2, wordWrap: true },
},
// This deepMerge is for preserving nested values when updating state via an action
// It operates at a different point in time than the merge option below
updateSettings: (partial) =>
set((state) => ({
settings: deepMerge({}, state.settings, partial),
})),
}),
{
name: 'settings-store',
partialize: (state) => ({ settings: state.settings }),
// This merge defines how to combine with currentState when restoring from storage
merge: (persistedState, currentState) =>
deepMerge({}, currentState, persistedState),
// Order matters:
// Lay down currentState defaults first, then overwrite with persistedState
// so that default values for newly added fields are preserved
}
)
)Caution: The order
deepMerge(currentState, persistedState)must be respected. Writing it the other way asdeepMerge(persistedState, currentState)will overwrite the user's saved settings with initial values.
Example 3: Handling Hydration Delay in a Next.js SSR Environment
Once you've set up auth state correctly, it's time to look at another problem that arises in Next.js environments.
When using the App Router, you'll encounter SSR hydration mismatch warnings fairly often. Before I introduced this pattern, I dismissed hydration warnings as "just a dev environment warning, I'll look at it later"—then experienced an issue in production where login state was briefly exposed. The server renders with the initial state while the client renders with the state restored from localStorage, so a mismatch is inevitable.
This can be resolved with skipHydration + an explicit rehydrate() call from a client component.
// store.ts
export const useStore = create<AppState>()(
persist(storeCreator, {
name: 'app-store',
skipHydration: true, // Skip automatic restoration
})
)// components/HydrationGate.tsx
'use client'
import { useEffect } from 'react'
import { useStore } from '@/store'
export function HydrationGate({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Only runs on the client, so no SSR/CSR mismatch occurs
useStore.persist.rehydrate()
}, [])
return <>{children}</>
}// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<HydrationGate>
{children}
</HydrationGate>
</body>
</html>
)
}Example 4: Detecting When Restoration Is Complete with onRehydrateStorage
Once you've handled SSR, the next thing to address is UI that depends on saved data rendering before restoration is complete. Using the onRehydrateStorage callback to manage a restoration-complete flag lets you cleanly handle loading-state flickers.
// store.ts
interface AppState {
user: User | null
_hasHydrated: boolean
setHasHydrated: (val: boolean) => void
}
export const useStore = create<AppState>()(
persist(
(set) => ({
user: null,
_hasHydrated: false,
setHasHydrated: (val) => set({ _hasHydrated: val }),
}),
{
name: 'app-store',
partialize: (state) => ({ user: state.user }), // _hasHydrated excluded from storage
onRehydrateStorage: () => (state, error) => {
if (!error) {
state?.setHasHydrated(true)
}
},
}
)
)// components/AuthGuard.tsx
'use client'
import { useStore } from '@/store'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const hasHydrated = useStore((s) => s._hasHydrated)
const user = useStore((s) => s.user)
// LoadingSpinner and LoginPage need their own implementations
if (!hasHydrated) return <LoadingSpinner />
if (!user) return <LoginPage />
return <>{children}</>
}Example 5: Combining Version Migration with partialize
When the schema changes, using the version + migrate options together lets you safely upgrade existing users' saved data to the new structure. Using structuredClone instead of direct mutation maintains immutability.
persist(storeCreator, {
name: 'app-store',
version: 2,
partialize: (state) => ({ user: state.user, preferences: state.preferences }),
migrate: (persistedState: any, version: number) => {
if (version === 1) {
// v1: user.name → v2: user.displayName
const state = structuredClone(persistedState)
if (state.user?.name) {
state.user.displayName = state.user.name
delete state.user.name
}
return state
}
return persistedState
},
})When migration runs:
migrateruns whenversiondiffers from the saved value. Since only the structure saved bypartializeis subject to migration, it's recommended to incrementversionwhenever the set of saved fields changes.
Pros and Cons
The moment I felt the limits of these options most acutely in practice was when user complaints started coming in after a deployment. "I never changed my settings, but my notifications are turned off"—that was the shallow merge trap.
Pros
| Item | Details |
|---|---|
| Fine-grained control over what gets saved | partialize lets you selectively exclude sensitive data (tokens) and volatile UI state |
| Reduced storage usage | Only saving what's needed makes efficient use of the localStorage 5MB limit |
| Customizable merge strategy | merge lets you safely restore nested objects without losing new fields |
| Natural integration with the slice pattern | The persistence scope can be explicitly managed in one place (partialize) |
| Version migration support | version + migrate lets you handle schema changes flexibly |
Cons and Caveats
| Item | Details | Mitigation |
|---|---|---|
| The default shallow merge trap | New fields in nested objects can disappear on restore | Explicitly pass a deepMerge function to merge |
partialize doesn't filter subscriptions |
Serialization runs even for state changes not in the saved set | Only relevant for high-frequency state change slices; performance impact is negligible in typical cases |
| Functions can't be serialized | Including actions (functions) in the partialize return value breaks restoration |
Always exclude functions from partialize; restore them via currentState's functions in merge |
| SSR hydration mismatch | Automatic restoration in Next.js causes divergence between server and client state | skipHydration: true + explicit rehydrate() call on the client is recommended |
| Can't independently persist per slice within a single store | Giving each slice its own persist configuration within a single combined store is not supported at the official API level |
Either separate your domain stores and apply persist to each individually, or use zustand-slices (under the official pmndrs umbrella) which helps manage per-slice persistence |
The Most Common Mistakes in Practice
-
Including functions (actions) in
partialize: Returning functions likesetUserorclearUserin the return value causes serialization errors or results in the functions beingundefinedafter restoration.partializeshould only return pure data fields. -
Not configuring
mergefor slices with nested objects: Everything looks fine on first deployment, but later only users who have saved data see new fields asundefined, making the cause hard to track down. -
Using
persistin Next.js withoutskipHydration: In development, only a warning is shown, but in production, sensitive UI like auth state can be briefly exposed.
Closing Thoughts
Explicitly configuring partialize and merge isn't just a feature configuration. It's an act of declaring in code which state belongs to the user and which belongs to the app. The clearer you draw this boundary, the more you can prevent unexpected UX bugs from appearing after deployment.
Three steps you can try right now:
-
Try adding
partializeto your existingpersistconfiguration. Start by returning everything withpartialize: (state) => ({ ...state }), then remove fields one by one to audit which state actually needs to be persisted. -
If you have nested objects, it's recommended to wire
deepMergeintomerge. Install it withpnpm add lodash-esorpnpm add deepmerge, then configure it asmerge: (persisted, current) => deepMerge({}, current, persisted). -
If you're using Next.js, consider adopting
skipHydration: trueand theHydrationGatepattern. Not only will hydration warnings disappear, but the_hasHydratedflag also lets you manage loading state cleanly.
References
- persist - Zustand Official Reference — The latest reference for all options including
partialize,merge, andonRehydrateStorage - Persisting store data - Zustand Official Integration Guide — The best page in the official docs for practical usage patterns
- Slices Pattern - Zustand Official Guide — If you're new to the slice pattern, read this before this post
- Solving zustand persisted store re-hydration merging state issue | DEV Community — A post that walks through the
mergetrap with a real example; it inspired thedeepMergeexample in this post - Using selective persist with multiple slices · GitHub Discussion #985 — A thread requesting independent persist per slice, with responses from the official team
- Persist a single Slice in a Bounded store · GitHub Discussion #1630 — Discussion on persisting only a single slice in a combined store
- Clarification on persist middleware's partialize option · GitHub Discussion #1273 — Direct explanation of how
partializeworks from a maintainer - zustand-slices GitHub Repository — A utility dedicated to the slice pattern, created by Zustand's official maintainer (Daishi Kato)
- Fix Next.js 14 hydration error with Zustand | Medium — A post covering real-world application of the
skipHydrationpattern