Skip to content
D
Documentation

Migrate persisted state

how-to
3 min readUpdated

Use versioning when you change the schema stored by persist, such as moving flat coordinates into a nested position object. Add a custom merge when stored objects omit fields that your current store supplies as defaults.

This browser example migrates version 0 data into version 1 and renders a red dot at x: 40, y: 100. The stored y survives migration; the missing x comes from the current store's nested defaults.

1. Version the store and migrate the old schema

Keep the create and createJSONStorage setup from Persist store data; add version, migrate, and merge to handle the schema change below.

In your React TypeScript project, install Zustand:

bash
npm install zustand

Create position-store.ts. The seed represents an older, partially persisted schema and only runs when this example's storage key is absent. Remove the seeding code from your application after testing the migration.

ts
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'

type PositionStore = {
  position: { x: number; y: number }
  moveRight: () => void
}

type PersistedPosition = {
  position: Partial<PositionStore['position']>
}

// Browser demonstration: seed an older schema before creating the store.
const storageName = 'position-migration-example'
if (localStorage.getItem(storageName) === null) {
  localStorage.setItem(
    storageName,
    JSON.stringify({ state: { y: 100 }, version: 0 }),
  )
}

export const usePositionStore = create<PositionStore>()(
  persist(
    (set) => ({
      position: { x: 40, y: 40 },
      moveRight: () =>
        set((state) => ({
          position: { ...state.position, x: state.position.x + 20 },
        })),
    }),
    {
      name: storageName,
      storage: createJSONStorage<PersistedPosition>(() => localStorage),
      version: 1,
      partialize: (state): PersistedPosition => ({
        position: state.position,
      }),
      migrate: (persisted, version): PersistedPosition => {
        if (
          version === 0 &&
          typeof persisted === 'object' &&
          persisted !== null
        ) {
          return {
            position: {
              ...('x' in persisted && typeof persisted.x === 'number'
                ? { x: persisted.x }
                : {}),
              ...('y' in persisted && typeof persisted.y === 'number'
                ? { y: persisted.y }
                : {}),
            },
          }
        }
        // This example supports only the version 0 migration.
        return { position: {} }
      },
      merge: (persisted, current) => {
        if (
          typeof persisted !== 'object' ||
          persisted === null ||
          !('position' in persisted) ||
          typeof persisted.position !== 'object' ||
          persisted.position === null
        ) {
          return current
        }
        const position = persisted.position
        return {
          ...current,
          position: {
            ...current.position,
            ...('x' in position && typeof position.x === 'number'
              ? { x: position.x }
              : {}),
            ...('y' in position && typeof position.y === 'number'
              ? { y: position.y }
              : {}),
          },
        }
      },
    },
  ),
)

migrate receives the stored state and its stored version, not the target version. Return data compatible with the current persisted schema; the callback can also return a promise. Here it moves the old x and y fields into position, without inventing values for missing coordinates.

Hydration calls merge with the migrated data and the current state. After a successful migration, persist writes the merged, filtered state back to storage with version 1. partialize stores only position, leaving the action out of the stored data.

2. Preserve nested defaults and render the result

The custom merge copies current.position first, then overlays valid stored coordinates. It returns the full store, including moveRight.

The default shallow merge can erase unpersisted nested fields: a stored { position: { y: 100 } } replaces the entire current position object. Merge that nested object explicitly, as above, or use a deep merge for a larger schema. Keep the current state as the base and let persisted fields override it.

Mount a component that selects the position and action separately:

tsx
import { createRoot } from 'react-dom/client'
import { usePositionStore } from './position-store'

function App() {
  const position = usePositionStore((state) => state.position)
  const moveRight = usePositionStore((state) => state.moveRight)

  return (
    <main>
      <p>x: {position.x}, y: {position.y}</p>
      <button onClick={moveRight}>Move right</button>
      <div
        style={{
          position: 'relative',
          height: 240,
          width: 400,
          border: '1px solid gray',
        }}
      >
        <div
          style={{
            position: 'absolute',
            left: position.x,
            top: position.y,
            width: 20,
            height: 20,
            borderRadius: '50%',
            background: 'red',
          }}
        />
      </div>
    </main>
  )
}

createRoot(document.getElementById('root')!).render(<App />)

Reuse the index.html root element from React quick start, with the module script's src set to /main.tsx to load this migration example.

On the first run with an absent example key, the readout shows x: 40, y: 100, and the red dot appears at those offsets inside the bordered container. Click Move right to increase x by 20. Reload to see the saved position restored; version 1 data goes through merge without running migrate again.

Options that matter

OptionTypeDefaultWhat it does
namestringRequiredSets the unique storage key. Keep the key when changing the schema so migration can read the old data.
versionnumber0Sets the version written with persisted state. A different numeric stored version triggers migration.
migrate(persistedState: unknown, version: number) => PersistedState | Promise<PersistedState>Not providedConverts mismatched-version data to the current persisted schema. Without it, mismatched-version data is not used.
merge(persistedState: unknown, currentState: State) => State{ ...currentState, ...persistedState }Combines hydrated data with the current store. Supply a nested merge to retain omitted defaults.
partialize(state: State) => PersistedState(state) => stateFilters what is written to storage.

In the table, State and PersistedState denote your store and persisted-data types.

Check the migration boundary

  • Increment version for a breaking storage-schema change and handle each supported old version in migrate. The example handles version 0; other mismatched versions return an empty position, so the merge retains current defaults.
  • Migration runs only when the stored version is a number different from the configured version. A missing version does not trigger it. The custom merge still handles same-version partially persisted objects.
  • These files run in the browser and use synchronous localStorage. For asynchronous storage and initial-render behavior, see Persist store data. For manual hydration, see Control persistence hydration.

Was this page helpful?

Migrate persisted state — zustand · GPT-6.1 Sol