Learning Zustand - Advanced Persistence & Rehydration
Episode 11 of 23

Learning Zustand - Advanced Persistence & Rehydration

This episode covers advanced persistence and rehydration: custom storage with createJSONStorage for sessionStorage and backends, serialization and deserialization, and versioning with migrate for migrating old state. You also learn to handle state that fails to rehydrate via the onRehydrateStorage callback.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Episode 8 introduced the persist middleware with built-in localStorage. Episode 11 takes it further: how to save state to storage that isn't localStorage, how to control the serialization process, and how to migrate old state when the data shape changes across application versions. This is a must-have skill before your application goes to production.

Three main topics we'll break down: custom storage with createJSONStorage from sessionStorage to backend endpoints, the version plus migrate options for state that changes schema, and the onRehydrateStorage callback for handling failures when state is loaded back from storage.

Custom Storage with createJSONStorage

Built-in and Custom Storage

By default, persist uses localStorage. To change the storage destination, createJSONStorage produces a storage adapter that uses the standard getItem, setItem, and removeItem:

JSPersist to sessionStorage
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
 
interface SessionState {
  token: string
  setToken: (token: string) => void
}
 
export const useSession = create<SessionState>()(
  persist(
    (set) => ({
      token: '',
      setToken: (token) => set({ token }),
    }),
    {
      name: 'session-store',
      storage: createJSONStorage(() => sessionStorage),
    },
  ),
)

createJSONStorage(() => sessionStorage) accepts a function that returns a Storage object. Why a function, not the object directly? Because in environments like a server, sessionStorage isn't available when the module loads — with a function, access is deferred until it's actually needed.

Backend or Async Storage

Storage doesn't have to be a browser store. Zustand supports any async storage that implements the getItem, setItem, and removeItem contract. To save state to a backend API:

JSCustom storage to an API endpoint
const apiStorage = {
  getItem: async (name: string) => {
    const res = await fetch(`/api/storage/${name}`)
    return res.json()
  },
  setItem: async (name: string, value: string) => {
    await fetch(`/api/storage/${name}`, {
      method: 'POST',
      body: value,
    })
  },
  removeItem: async (name: string) => {
    await fetch(`/api/storage/${name}`, { method: 'DELETE' })
  },
}

Serialization and Deserialization

Persist saves state as a JSON string. By default, only JSON-supported values are stored — functions like store actions are automatically dropped. For non-JSON formats, you can override the default behavior with the serialize and deserialize options:

JSCustom serialization
persist(initializer, {
  name: 'cart',
  storage: createJSONStorage(() => localStorage, {
    reviver: (key, value) => (key === 'total' ? Number(value) : value),
    replacer: (key, value) => (key === 'total' ? value.toFixed(2) : value),
  }),
})

replacer and reviver follow the JSON.stringify and JSON.parse contracts. Use them to convert special types like Date or BigInt that native JSON doesn't support. For needs beyond JSON, write a custom storage that returns a raw string.

State Versioning and Migration

The version Option

State schemas can always change. When you add a new field or change the shape of an old one, the state already saved in the user's storage becomes outdated. The version option marks the state schema:

JSMarking the state schema version
export const useSettings = create<SettingsState>()(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    { name: 'app-settings', version: 2 },
  ),
)

version: 2 tells persist that the state is only valid for the version 2 schema. When you raise this number, persist knows the old state needs processing before it's used.

The migrate Function

Pair version with migrate to convert old state into the new shape:

JSMigrating state from an old version
migrate: (persisted, version) => {
  if (version === 0) {
    return { ...persisted, theme: 'light', fontSize: 14 }
  }
  if (version === 1) {
    return { ...persisted, fontSize: 14 }
  }
  return persisted
},

migrate(persisted, version) receives the read state and its version number. This function runs once at rehydration, then its result becomes the store's initial state. Without migrate, state with an unrecognized version is ignored.

Handling Rehydration Failures

The process of loading state back from storage is called rehydration. For async storage, this process can fail or finish after the first render. The onRehydrateStorage callback gives you an entry point to handle it:

JSThe onRehydrateStorage callback
onRehydrateStorage: () => (state, error) => {
  if (error) {
    console.error('Rehydration gagal', error)
    return
  }
  console.log('State dimuat dari storage', state)
},

onRehydrateStorage: () => (state, error) => ... is called when the rehydration process finishes. This is the right place for logging, syncing state to analytics, or showing a notification when the user's state is restored. Combine it with the skipHydration pattern from episode 8 for full control over the rehydration timing.

Closing

Episode 11 closes the advanced persistence layer: storage beyond localStorage via createJSONStorage, custom serialization for non-JSON types, versioning with migrate for changing schemas, and the onRehydrateStorage callback for rehydration failures.

Key takeaways:

  • createJSONStorage wraps any storage with the getItem, setItem, and removeItem contract.
  • Storage can be async, for example a backend API endpoint.
  • The version option marks the state schema and triggers migrate.
  • migrate converts old state into the latest shape at rehydration.
  • onRehydrateStorage handles both success and error results of rehydration.
  • Actions are not stored because persist writes only JSON values.

In the next episode we will discuss server state and data integration — distinguishing API data from UI state, when data goes into Zustand and when into TanStack Query, and the hybrid auth and user profile patterns.

Learning Zustand - Advanced Persistence & Rehydration | Learning Zustand