Zustand persist: version bump, migrate, and merge behavior for evolving nested state
0 reputation · 17 Oct 2021, 22:25 UTC
0 reputation · 17 Oct 2021, 22:25 UTC
I have a Zustand store using the persist middleware (default localStorage via createJSONStorage) with version: 1. The state shape is changing: a nested preferences object is gaining new fields with defaults, and one old field is being renamed. I need existing users' persisted snapshots to keep working after the update.
The docs describe a migrate function that runs when the persisted version differs from the configured version, and a merge option that controls how rehydrated state combines with current state. What is unclear to me is the interaction between the two: after migrate returns an updated snapshot, does the default shallow merge then overwrite my newly added nested defaults with the old nested object, dropping the new fields? I also need to know what happens to snapshots whose version has no matching migration path — whether they are discarded silently or trigger an error I can handle via onRehydrateStorage.
Assume zustand v4/v5; option signatures have changed across majors, so answers should note version assumptions.
migrate return a fully complete state, or is a custom deep merge function required to preserve new nested defaults?partialize interact with migration if the renamed field was previously excluded from storage?Have migrate return a complete persisted-shape state for the new version, including the renamed field and all new nested defaults. You do not need a custom merge function — but you do need to understand why: the default merge is a shallow merge of the migrated snapshot over current state. That means a top-level preferences object coming from storage replaces your current preferences object wholesale. If migrate returns the old nested object untouched, your newly added nested defaults are indeed dropped. The fix is to fill them in inside migrate, not to write a deep merge.
Bump version to 2 and make the migration explicit:
persist(
(set, get) => ({ /* ... */ }),
{
name: 'app-store',
version: 2,
migrate: (persisted, version) => {
if (version === 1) {
const old = persisted.preferences ?? {};
persisted.preferences = {
...defaultPreferences, // new fields get defaults
...old, // keep user's existing values
newName: old.oldName, // explicit rename
};
delete persisted.preferences.oldName;
}
return persisted;
},
}
)Because the migrated snapshot now contains the full nested object, the shallow default merge is safe. A custom merge: (persisted, current) => ... runs after migrate and is only worth it if you want generic deep-merging — which is error-prone (arrays, undefined, class instances) and usually less explicit than fixing the data in migrate.
This is the part that bites people. If the persisted version differs from the configured version and there is no migrate (or migrate has no branch for that version and returns undefined), zustand v4/v5 logs a console warning and silently discards the stored state, falling back to initial state. onRehydrateStorage's error callback fires for storage/JSON parse errors and for exceptions thrown inside migrate — but not for the plain version-mismatch-without-migrate path. To make discards observable, own that branch yourself:
migrate: (persisted, version) => {
if (version === 1) { /* ... */ return persisted; }
// Unknown version: preserve evidence before discarding
console.warn('Discarding snapshot at version', version, persisted);
try {
localStorage.setItem('app-store-backup', JSON.stringify({ version, persisted }));
} catch {}
return undefined; // falls back to initial state
}Note that migrate must be synchronous; async migration is not awaited by the default hydration flow.
partialize runs only when writing to storage, never during rehydration, so it cannot interfere with migration mechanics. But it defines what migrate receives: if the renamed field was previously excluded via partialize, it simply won't exist in old snapshots — treat it as absent and let defaults win. In v4/v5, migrate is typed to return the persisted (partialized) shape, so typing partialize and migrate consistently prevents the common bug of returning non-persisted fields.
localStorage with a hand-crafted version-1 snapshot, load with version: 2, and assert the rehydrated state has the renamed field and new defaults.Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.