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.

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.
By default, persist uses localStorage. To change the storage destination, createJSONStorage produces a storage adapter that uses the standard getItem, setItem, and removeItem:
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.
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:
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' })
},
}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:
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 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:
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.
Pair version with migrate to convert old state into the new shape:
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.
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:
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.
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:
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.