This episode covers the persist middleware that saves state to localStorage or sessionStorage automatically, the name option for the storage key, partialize for choosing the subset of state to store, and skipHydration for controlling the rehydration process.

A good application remembers user preferences: the chosen theme, items in the cart, or the last step of a wizard. Zustand provides the persist middleware that saves state to browser storage automatically and restores it when the application is opened again.
Episode 8 covers persist from the basics to advanced options: basic syntax, the name and partialize options, and skipHydration for full control of the rehydration process. Deeper persistence — custom storage, versioning, and migration — will be covered in episode 11.
Wrap the store initializer with persist(...):
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ThemeState {
theme: 'light' | 'dark'
toggle: () => void
}
export const useTheme = create<ThemeState>()(
persist(
(set) => ({
theme: 'light',
toggle: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
}),
{ name: 'theme-storage' },
),
)persist(initializer, { name: 'theme-storage' }) saves the state to localStorage under the key theme-storage. Every set automatically rewrites the storage; when the store is first created, the saved value is rehydrated back.
When the application loads, persist reads the storage, deserializes it, then merges it into the initial state. In the UI, rehydration runs almost imperceptibly for synchronous storage like localStorage — the brief difference is only visible in SSR applications, a topic for episode 14.
name determines the key in storage. Use a descriptive, unique name per store:
export const useCart = create<CartState>()(
persist(
(set) => ({
items: [],
discount: 0,
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
}),
{
name: 'cart-persist',
partialize: (state) => ({ items: state.items }),
},
),
)partialize: (state) => ({ items: state.items }) selects the subset to store — in this example only items, not discount or the action functions. Persist never stores functions; partialize filters data while avoiding unnecessary fields.
There are two reasons: security and size. Never persist sensitive data like tokens — and don't store large state that can be rebuilt. Action functions are automatically dropped by persist, but large data fields still consume storage space.
By default persist rehydrates immediately when the store is created. skipHydration: true delays that process until you call rehydrate() manually:
export const useSettings = create<SettingsState>()(
persist(
(set) => ({ language: 'id', notifications: true }),
{ name: 'settings', skipHydration: true },
),
)
export async function rehydrateSettings() {
await useSettings.persist.rehydrate()
}skipHydration: true holds back the rehydration, then useSettings.persist.rehydrate() runs it whenever needed. This is an important pattern for SSR applications: the first render uses the default state, then rehydration happens after the browser is ready — details in episode 14.
onRehydrateStorage gives you a hook before and after the rehydration process:
persist(
(set) => ({ user: null, token: null }),
{
name: 'session',
onRehydrateStorage: () => (state) => {
console.log('hydration selesai', state)
},
},
)onRehydrateStorage: () => (state) => ... accepts a callback that is called after rehydration finishes. It suits synchronizing migrated data or marking the application as ready to use.
Persist is most useful for state that must survive across sessions: theme, language preferences, shopping cart, form drafts, and data that doesn't depend on the server. Conversely, don't persist temporary state — loading status, errors, or data that can always be refetched.
Warning
Never store access tokens or secrets in localStorage via persist. This storage is vulnerable to XSS. The correct security patterns are covered in episode 15.
Episode 8 introduces persist as the middleware that guards state across sessions: automatic storage to localStorage, the name option for the storage key, partialize for choosing the subset, and skipHydration for full control of the rehydration process.
Key takeaways:
In the next episode we will discuss the immer middleware — updating nested state in a mutable-but-immutable way through draft functions, comparing it with manual spread, and the performance trade-offs in choosing between them.