A Zustand cart store wrapped in persist gains a nested shipping.method field, and returning users see it as undefined after the release; why, and how do you fix it?
answer
- what is saved next to the state
- how stored and fresh state combine
- a one-level default
- a number that gates migration
- choose what gets written
basics
~20 spersist rehydrates with a shallow merge, so the stored shipping object replaces the new default one and method never appears. Bump the persist version and add a migrate function that fills the field, or supply a deep merge.
solid answer
~40 sZustand's `persist` middleware writes `{ state, version }` under its `name` key, in `localStorage` by default. On load it reads that value, and if the stored `version` equals `options.version` (default `0`) it combines it with the fresh initial state using the default `merge`, a **shallow** spread: `{ ...currentState, ...persistedState }`. The stored `shipping` object, written before `method` existed, therefore replaces the new default `shipping` wholesale, and `method` is `undefined`. The durable fix is to bump `version` to `1` and add `migrate(persistedState, version)` that fills `shipping.method`; persist writes the migrated state back once. A custom `merge` that deep-merges nested objects also works, and `partialize` should limit what gets stored so fewer shapes have to evolve at all.
code
ts · 29 linesimport { create } from 'zustand'
import { persist } from 'zustand/middleware'
type Shipping = { address: string | null; method: 'standard' | 'express' }
type CartState = { items: string[]; shipping: Shipping; isDrawerOpen: boolean }
type PersistedCart = Pick<CartState, 'items' | 'shipping'>
export const useCartStore = create<CartState>()(
persist(
() => ({
items: [],
shipping: { address: null, method: 'standard' },
isDrawerOpen: false,
}),
{
name: 'cart',
version: 1,
// keep UI flags out of storage
partialize: (s): PersistedCart => ({ items: s.items, shipping: s.shipping }),
migrate: (persisted, version) => {
const state = persisted as PersistedCart
if (version < 1) {
state.shipping = { ...state.shipping, method: 'standard' }
}
return state
},
},
),
)go deeper
Know that persist needs a unique name, stores to localStorage by default, and saves both the state and a version number.
Explain the hydration order: read, version check, migrate, then merge, and why the default merge is a one-level spread that lets a stored nested object hide new defaults.
Treat persisted state as a schema: version it, write migration chains, partialize what is stored, and know that a version bump without migrate silently discards user data.
Decide what the product actually needs to survive a reload; every persisted field becomes a long-lived contract with every returning browser, so keep that surface small.
## What `persist` stores and when it reads it The `persist` middleware from `zustand/middleware` wraps a store creator and mirrors state into a storage. Its only required option is `name`, the storage key. By default the storage is `createJSONStorage(() => window.localStorage)`, so every write is a JSON string of `{ state, version }`, where `state` is the result of `partialize(state)` (the whole state by default) and `version` is `options.version` (default `0`). Functions such as actions do not survive `JSON.stringify`, which is harmless because the fresh creator supplies them again. Writes happen on every `set`/`setState` through the middleware. Reads happen during **hydration**, which runs when the store is created unless `skipHydration: true` is set. In Zustand 5 (since 4.5.5) persist no longer writes the initial state at creation; storage is written when hydration runs a migration, and otherwise only on the first update. ## Why the new field is missing Hydration does this, in order: 1. Read the stored `{ state, version }` for `name`. 2. If the stored `version` differs from `options.version`, call `migrate(persistedState, storedVersion)` when one is provided; without `migrate`, log an error and ignore the stored state. 3. Combine the stored (or migrated) state with the current state using `merge(persistedState, currentState)`. 4. Replace the store's state with that result, write it back if a migration ran, then fire `onRehydrateStorage`'s returned callback and the `onFinishHydration` listeners. The default `merge` is `{ ...currentState, ...persistedState }` — **one level**. Consider the release: | | Before the release | After the release | |---|---|---| | Initial `shipping` | `{ address: null }` | `{ address: null, method: 'standard' }` | | Stored `shipping` for a returning user | `{ address: '12 Main St' }` | unchanged in storage | | Result of default `merge` | — | `{ address: '12 Main St' }` | The stored `shipping` object wins the top-level spread and carries no `method`. New users are fine; returning users hit `undefined` wherever the checkout reads `shipping.method`. A new **top-level** key would have survived, because the stored object simply lacks it and the spread keeps the default; it is nesting that makes this bite. ## Fixing it properly: `version` plus `migrate` The mechanism designed for schema changes is the version gate: - set `version: 1` in the persist options; - add `migrate(persistedState, version)` that upgrades any older shape and returns the new state (it may also return a Promise); - persist runs it for every stored value whose version is not `1`, merges the result, and writes the upgraded state back so it runs once per user. A migration chain (`if (version < 1) ...; if (version < 2) ...`) keeps old installs working after several releases. Forgetting `migrate` after bumping `version` is its own trap: persist logs an error and discards the stored state, so users lose their carts silently. ## Other levers - **Custom `merge`** — pass a deep merge so nested defaults fill gaps. It fixes additive changes without a version bump, but it cannot rename or retype fields, and it can resurrect keys you meant to remove. - **`partialize`** — persist only what must outlive a reload, for example `(s) => ({ items: s.items, shipping: s.shipping })`. UI flags, loading states and derived values then never become a schema you must migrate. - **Flatter persisted state** — top-level keys merge gracefully with the default `merge`; deeply nested persisted objects multiply migration work. ## Hydration timing, briefly With a synchronous storage such as `localStorage`, hydration completes while the store is being created. With an asynchronous storage, the first renders see the initial state and the stored state arrives later; `useCartStore.persist.hasHydrated()` and `persist.onFinishHydration(cb)` let a component wait. For server-rendered pages, `skipHydration: true` plus a `persist.rehydrate()` call in an effect puts hydration after mount, under your control. ## Composition order with other middleware When `persist` sits beside `devtools` and `immer`, the docs recommend applying `devtools` last (outermost), for example `devtools(persist(immer(creator), { name: 'cart' }))`, because `devtools` changes `setState`'s type and another middleware applied after it could lose that change. Apply middleware to the combined store, not inside individual slices.
- In Zustand's persist, what happens if you bump version to 1 but forget to provide migrate?On hydration persist sees the stored version 0 differ from 1, finds no `migrate`, logs an error that the state could not be migrated, and ignores the stored state. The store keeps its initial state, and the next update overwrites storage. Returning users silently lose their saved cart, which is often worse than the original bug.
- With Zustand's persist over an asynchronous storage, why might the cart badge show 0 for a moment, and how do you avoid it?Hydration with an async `getItem` finishes after the first renders, which see the initial empty cart. Components can wait for `useCartStore.persist.hasHydrated()` to be true, subscribing via `persist.onFinishHydration`, and render a placeholder until then. With synchronous `localStorage` this gap does not occur on the client because hydration completes during store creation.
- Where should devtools go when a Zustand store also uses persist and immer?Outermost: `devtools(persist(immer(creator), options))`. The docs recommend applying `devtools` as late as possible because it changes the type of `setState`, and a middleware wrapped around it could lose that change.
saying these in an interview costs you the question
- persist deep-merges stored state into the defaults, so new nested fields appear automatically.
- Bumping the persist version alone is enough; old stored state is upgraded for you.
- Actions must be excluded with partialize or JSON serialisation will fail.
- persist writes the initial state to storage as soon as the store is created in v5.
- Adding a new top-level key breaks returning users the same way a nested key does.