Safely Evolving Production localStorage Schemas with Zustand persist's version and migrate
This article is aimed at those who are already using Zustand
persistor considering adopting it. It assumes some familiarity with Zustand basics and TypeScript.
If you run a production service long enough, you will inevitably encounter this situation: you need to change your state structure, but an older version of the data is already stored in the localStorage of thousands of users' devices. I once thought "it'll probably be fine" — and quietly watched the app break. The mismatch between existing stored data and the new code structure led to undefined access errors, or values being silently overwritten with empty ones.
After reading this article, you'll be able to immediately apply a technique for tracking schema change history with version, declaratively managing version-to-version conversion logic with migrate, and evolving your schema without losing user data — even during an active deployment. If you're on Zustand v5 in particular, extra care is needed. A change in auto-persist behavior in v5 can cause existing migration code to behave unexpectedly.
We'll walk through multi-version cumulative migrations, SSR environment handling, TypeScript type considerations, and common pitfalls in real-world development — all with actual code.
Table of Contents
Core Concepts
How the persist middleware stores state
When persist is applied, the store's state is saved to storage in the following format.
{
"state": { "count": 5, "user": { "name": "철수" } },
"version": 2
}If the version field is absent, the default is 0. When the app loads, the persist middleware reads this object from storage and compares it against the version specified in the code.
Load from storage
→ Check stored version
→ Compare with code's version
├─ Match: rehydrate as-is
└─ Mismatch: run migrate() → rehydrate with transformed state → update storage with new versionRehydrate: The process of reading serialized data saved in storage and restoring it as in-memory store state. Conceptually the same as "hydration" in server-side rendering.
Conditions under which migrate is called
The signature of the migrate function is as follows.
migrate: (persistedState: unknown, fromVersion: number) => NewState | Promise<NewState>It is called when the stored version is lower than the code's version. Once migration completes, the middleware writes the transformed state and the new version number back to storage, so migration will not re-run on the next load.
So what happens if you change the schema without specifying a version? Even if the stored data doesn't match the new structure, the middleware will simply attempt to rehydrate using the existing data. If there is no migrate and the versions don't match, Zustand discards the stored state, invokes the failure callback in onRehydrateStorage, and starts from the initial values — from the user's perspective, their data has suddenly vanished.
There's one more thing worth noting: what happens if the migrate function throws an exception? In this case too, Zustand abandons the stored state and falls back to initial values. This is why it's recommended in practice to wrap migrate internals in a try-catch and return a safe default value on error.
What changed in Zustand v5
One important behavior changed in v5 (2024–2025). Previously, the initial state was automatically saved to storage on the app's first run, even without any user interaction — starting with v5, storage is only written when an actual state change occurs. When writing a migrate function, you must account for the case where storage is completely empty.
If you upgraded to Zustand v5 without changing existing code, persistedState may arrive as null or undefined during a new user's first session. Accessing state.someField without defensive handling will cause a runtime error. If you're on v5, this is something worth checking carefully.
Practical Application
Example 1: Renaming a field
This is the most common case. A field named bearCount has been renamed to count. Existing users have bearCount stored on their devices.
interface BearState {
count: number
increment: () => void
}
const useBearStore = create<BearState>()(
persist(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{
name: 'bear-storage',
version: 1,
migrate: (persistedState: unknown, version: number): BearState => {
const { bearCount, ...rest } = persistedState as Record<string, unknown>
if (version === 0) {
return {
...rest,
count: (bearCount ?? 0) as number,
} as BearState
}
return rest as BearState
},
}
)
)| Code Point | Description |
|---|---|
version: 1 |
Explicitly marks that the schema has changed. Users on the old version will trigger migrate |
version === 0 condition |
Applies the transformation only for data saved in v0 |
?? 0 |
Defensive handling against empty storage (post-v5) or malformed data |
| Spread pattern | Returns a new object rather than mutating the original — prevents side effects |
Example 2: Multi-version cumulative migration
In a real service, you'll inevitably end up with v0 users, v1 users, and v2 users all coexisting. It's an unavoidable situation once a service has been around for a while. Using the version < N pattern to stack conditions allows any version of user to safely reach the latest state. When I first saw this pattern, I thought "really, it's that simple?" — but after using it in practice, it turns out to be quite an elegant solution.
type BearStateV3 = {
count: { value: number; unit: string }
lastUpdated: string | null
}
const useBearStore = create<BearStateV3>()(
persist(
(set) => ({
count: { value: 0, unit: 'bears' },
lastUpdated: null,
}),
{
name: 'bear-storage',
version: 3,
migrate: (persistedState: unknown, version: number): BearStateV3 => {
let state = persistedState as Record<string, unknown>
if (version < 1) {
// v0 → v1: rename bearCount to count
const { bearCount, ...rest } = state
state = { ...rest, count: bearCount ?? 0 }
}
if (version < 2) {
// v1 → v2: restructure count (number) into { value, unit } object
const { count, ...rest } = state
state = { ...rest, count: { value: (count ?? 0) as number, unit: 'bears' } }
}
if (version < 3) {
// v2 → v3: add lastUpdated field
state = { ...state, lastUpdated: null }
}
return state as BearStateV3
},
}
)
)Key pattern: There's a reason to use
if (version < N)instead ofif (version === N). A v0 user passes through all three conditions and arrives at the latest state, while a v2 user only passes through the last condition. Because each condition operates independently, you can handle users of every version in one place.
This example introduces a nested object (count as a { value, unit } structure), which is where you need to watch out for the shallow merge pitfall.
Shallow merge: A method of combining objects that only overwrites top-level keys. Shallow-merging
{ a: { c: 2 } }into{ a: { b: 1 } }yields{ a: { c: 2 } }—bis gone. Since this is the default behavior of Zustandpersist, it's safer to specify a deep merge function via themergeoption for schemas with nested objects.
import deepmerge from 'deepmerge'
persist(
/* ...store definition... */,
{
name: 'bear-storage',
merge: (persisted, current) =>
deepmerge(current as object, persisted as object),
}
)Example 3: Limiting migration scope with partialize
partialize is the option to use when you want only part of the store's state saved to storage. Values like temporary UI state that can be discarded at the end of a session don't need to be persisted. Reducing what gets saved naturally reduces what needs to be migrated.
interface AuthState {
user: User | null
token: string
tempUIState: boolean // No need to save — UI state that doesn't need to persist between sessions
}
const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: '',
tempUIState: false,
}),
{
name: 'auth-storage',
version: 2,
// Only user and token are saved to storage → migrate targets only these two
partialize: (state) => ({ user: state.user, token: state.token }),
migrate: (
persistedState: unknown,
version: number
): Pick<AuthState, 'user' | 'token'> => {
const state = persistedState as Record<string, unknown>
if (version < 2) {
const { accessToken, ...rest } = state
return {
...rest,
token: (accessToken ?? '') as string,
} as Pick<AuthState, 'user' | 'token'>
}
return state as Pick<AuthState, 'user' | 'token'>
},
}
)
)tempUIState is not saved to storage. You might wonder "doesn't that mean tempUIState becomes undefined after rehydration?" — it doesn't. Zustand merges the values read from storage with the store's initial values, so tempUIState is automatically populated with its initial value of false.
There are also cases where async migration is needed. Since migrate can return a Promise, server API-based transformations are possible too.
migrate: async (persistedState: unknown, version: number): Promise<UserState> => {
const state = persistedState as Record<string, unknown>
if (version < 1 && state.userId) {
// Fetch the latest profile from the server and migrate
const profile = await fetchUserProfile(state.userId as string)
return { ...state, profile } as UserState
}
return state as UserState
},Note that async migration can delay rehydration completion, so it's worth planning for loading state handling alongside it.
Example 4: Safe rehydration in an SSR (Next.js App Router) environment
If you're not using Next.js, you can skip this section. In a React-only environment, localStorage is only accessed on the client, so this issue does not arise.
When the server-rendered HTML differs from the client's localStorage values, a hydration mismatch occurs. Using skipHydration to skip rehydration during server rendering and then running it manually only on the client has become the de facto standard pattern.
// store.ts
export const useAppStore = create<AppState>()(
persist(
(set) => ({ /* initial state */ }),
{
name: 'app-storage',
version: 1,
skipHydration: true, // Disable automatic rehydration on the server
}
)
)// HydrationProvider.tsx
'use client'
import { useEffect } from 'react'
import { useAppStore } from './store'
export function HydrationProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Manually trigger rehydration after client mount
useAppStore.persist.rehydrate()
}, [])
return <>{children}</>
}// layout.tsx (Server Component)
import { HydrationProvider } from './HydrationProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<HydrationProvider>{children}</HydrationProvider>
</body>
</html>
)
}Pros and Cons
Advantages
| Item | Details |
|---|---|
| No additional dependencies | Built into Zustand core. No separate package installation needed |
| Flexible storage backend | Supports localStorage, sessionStorage, IndexedDB, AsyncStorage, and cookies |
| Async migration | migrate can return a Promise, enabling server API-based transformations |
| Incremental adoption | Start from version: 0 to adopt without modifying existing code |
| Graceful degradation | Warns and continues normal operation when storage is inaccessible (e.g., private browsing) |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Default merge is shallow | New fields in nested objects can be lost | Specify a separate function like deepmerge in the merge option |
| Lack of TypeScript type safety | persistedState is unknown, return type inference is incomplete |
Declare explicit return types in migrate + use zod for runtime validation if needed |
| Data loss on missing version bump | If version is not incremented after a schema change, existing data is ignored | Manage version constants with a TypeScript enum, enforce version bumps with linter rules |
| v5 removal of initial auto-persist | Storage may be empty on first run | Add null/undefined handling in migrate |
| Complexity of managing many versions | The migrate function grows large as versions accumulate |
Consider adopting the future-proof library |
| SSR hydration mismatch | Server/client state inconsistency can occur | Apply skipHydration + useEffect pattern |
The Most Common Mistakes in Practice
To be honest, I've personally made each of the following at least once.
-
Starting a service without
version: 0. If you add it later, the stored data of existing users won't have a version field at all (undefined), which makes the logic for determining whether it equals0or not unreliable. Declaring it from day one lays the groundwork for all future changes. One line of code prevents a future headache. -
Not handling empty storage in the
migratefunction. Starting with Zustand v5, storage can be empty on first run. Without defensive null coalescing like(state.count ?? 0),undefinedcan flow into state. I didn't catch this locally — I first learned about it from new user bug reports after a deployment. -
Not testing migration logic. It's especially important to verify with unit tests that cases where an intermediate version is skipped (e.g., a user upgrading directly from v0 to v3) behave as expected. The
migratefunction is close to a pure function, which makes it very easy to test. Create fixtures for each version's stored state, callmigratedirectly, and confirm the output. After a deployment, it becomes much harder to reproduce issues using real user data.
Closing Thoughts
Here are three steps you can take right now.
-
Add
version: 0to your existing stores. Even if you haven't changed the schema yet, explicitly specifying the version in thepersistoptions lays the groundwork for future changes. One line of code is enough:version: 0. -
On your next schema change, increment
versionby 1 and write amigrate. Write the field transformation logic for what changed in the new version using theif (version < N)pattern — and from that point on, any user on any older version will be handled safely. -
Write unit tests for
migrate. Create fixtures for each version's input state, call themigratefunction directly, and verify the output matches expectations. It's recommended to include cases for v0 → latest and intermediate version → latest.
version and migrate are design elements worth considering from the moment you start using persist. Before you find yourself in a situation of "I started this without a version, now what?" — adding just version: 0 right now makes a significant difference.
References
- Persisting store data | Zustand Official Docs
- persist middleware API Reference | Zustand Official Docs
- Zustand v5 Migration Guide
- persist migrate multi-version support issue #984 | GitHub pmndrs/zustand
- future-proof library proposal and discussion #2082 | GitHub pmndrs/zustand
- migrate return type discussion #2357 | GitHub pmndrs/zustand
- v4.5.5 removal of initial state auto-save breaking change #2763 | GitHub pmndrs/zustand
- How to migrate Zustand local storage store to a new version | DEV.to
- Solving zustand persisted store re-hydration merging state issue | DEV.to
- persist Middleware DeepWiki | pmndrs/zustand