Skip to content
D
Documentation

Control persistence hydration

how-to
3 min readUpdated

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.

ts
import { 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.

tsx
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)

  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:

tsx
import { 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:

tsx
import { 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 }.

OptionTypeDefaultWhat it does
namestringRequiredIdentifies the storage entry; use a unique key per store.
storagePersistStorage<{ bears: number }>createJSONStorage(() => window.localStorage)Reads and writes persisted state.
partialize(state: S) => { bears: number }Identity functionFilters state before writing; the sample saves only bears.
onRehydrateStorage(state: S) => ((state?: S, error?: unknown) => void) | voidNot setRuns logic before hydration and after success or failure.
skipHydrationbooleanfalseDisables 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 or onFinishHydration to report errors: the middleware catches hydration errors and passes them to the returned onRehydrateStorage callback. onFinishHydration runs on the success path.
  • Deferring hydration does not replace per-request store setup in server-rendered applications. See Next.js store setup.

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.

Was this page helpful?

Control persistence hydration — zustand · GPT-6.1 Sol