Use hydration tracking when your UI depends on saved state: display a loading message until storage has been read, and display an error instead of treating defaults as restored values. Hydration retrieves persisted state and merges it with the current store. For storage timing, see Persist store data.
1. Track completion and errors
Build on the create, persist, and createJSONStorage setup in Persist store data by tracking hydration status separately from persisted data.
The outer onRehydrateStorage callback runs before hydration. Its returned callback receives the restored state on success, or an error on failure. Keep the loading and error state in a separate, non-persisted store so it is not restored from storage itself. This also lets the callbacks update status during synchronous store initialization without updating the store being constructed.
Add these files to your React browser project with zustand installed. The sample follows the authors' callback pattern and uses separate selectors for each value your components need.
tsimport { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
type HydrationState = {
ready: boolean
error: string | null
}
export const useHydrationStore = create<HydrationState>()(() => ({
ready: false,
error: null,
}))
type BearStore = {
bears: number
addABear: () => void
}
export const useBearStore = create<BearStore>()(
persist(
(set) => ({
bears: 0,
addABear: () => set((state) => ({ bears: state.bears + 1 })),
}),
{
name: 'hydration-bears',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ bears: state.bears }),
skipHydration: false,
onRehydrateStorage: () => {
useHydrationStore.setState({ ready: false, error: null })
return (_state, error) => {
if (error !== undefined) {
useHydrationStore.setState({
ready: false,
error: error instanceof Error ? error.message : String(error),
})
} else {
useHydrationStore.setState({ ready: true, error: null })
}
}
},
},
),
)
Select the status before displaying the counter. The counter and its button appear after successful hydration; a storage read or JSON parsing failure displays an alert instead.
tsximport { useBearStore, useHydrationStore } from './store'
export default function App() {
const ready = useHydrationStore((state) => state.ready)
const error = useHydrationStore((state) => state.error)
const bears = useBearStore((state) => state.bears)
const addABear = useBearStore((state) => state.addABear)
if (error !== null) {
return <p role="alert">Could not restore saved bears: {error}</p>
}
if (!ready) {
return <p role="status">Loading saved bears…</p>
}
return (
<section>
<p>Bears: {bears}</p>
<button onClick={addABear}>Add a bear</button>
</section>
)
}
Mount the component in your project's root element:
tsximport { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(<App />)
Click Add a bear, then reload the page. The counter displays the saved count after hydration. With synchronous localStorage, hydration can finish before the first render, so you may not see the loading message. With no saved entry, successful hydration keeps the initial count of zero.
2. Defer hydration until mount
To control when the first hydration starts, change skipHydration to true in store.ts. This suppresses hydration during store initialization. Replace App.tsx with the following component to trigger it explicitly after mount, following the authors' useEffect pattern:
tsximport { useEffect } from 'react'
import { useBearStore, useHydrationStore } from './store'
export default function App() {
const ready = useHydrationStore((state) => state.ready)
const error = useHydrationStore((state) => state.error)
const bears = useBearStore((state) => state.bears)
const addABear = useBearStore((state) => state.addABear)
useEffect(() => {
void useBearStore.persist.rehydrate()
}, [])
if (error !== null) {
return <p role="alert">Could not restore saved bears: {error}</p>
}
if (!ready) {
return <p role="status">Loading saved bears…</p>
}
return (
<section>
<p>Bears: {bears}</p>
<button onClick={addABear}>Add a bear</button>
</section>
)
}
The initial render displays the loading message. The effect calls rehydrate(), and the same callbacks reveal the restored counter or the error alert. The public return type of rehydrate() is Promise<void> | void; use await when subsequent code needs to wait for an asynchronous hydration attempt, but use the callback's error argument to determine whether it succeeded.
Options that matter
In this table, S is your store state; the sample persists only { bears: number }.
| Option | Type | Default | What it does |
|---|---|---|---|
name | string | Required | Identifies the storage entry; use a unique key per store. |
storage | PersistStorage<{ bears: number }> | createJSONStorage(() => window.localStorage) | Reads and writes persisted state. |
partialize | (state: S) => { bears: number } | Identity function | Filters state before writing; the sample saves only bears. |
onRehydrateStorage | (state: S) => ((state?: S, error?: unknown) => void) | void | Not set | Runs logic before hydration and after success or failure. |
skipHydration | boolean | false | Disables the initial automatic hydration when true. |
Pitfalls
- Do not infer readiness from the counter's default value. Select an explicit status, as above. For asynchronous storage timing, see Persist store data.
hasHydrated()is a non-reactive getter, not a subscription. Calling it during render does not subscribe the component to hydration status. The sample uses a store subscription instead.- Do not rely on a rejected
rehydrate()promise oronFinishHydrationto report errors: the middleware catches hydration errors and passes them to the returnedonRehydrateStoragecallback.onFinishHydrationruns on the success path. - Deferring hydration does not replace per-request store setup in server-rendered applications. See Next.js store setup.
Live demo and related reading
For a running example of persistence using another storage source, open the authors' URL-hash persistence demo. It demonstrates URL-backed storage rather than the loading and error UI above; Connect state to the URL explains that integration.
- Persist store data — choose storage and decide which fields to save.
- Migrate persisted state — handle versions and nested-state merging.
Was this page helpful?