# Fix the Zustand persist hydration error

> A Zustand hydration error happens when persist loads saved state before hydration. Fix it with skipHydration and rehydrate(), or wait for hydration to finish.

Source: https://hydration.jscrate.dev/docs/guides/zustand-hydration-error
Last updated: 2026-09-18

A Zustand hydration error happens when a store that uses the `persist` middleware loads saved state from `localStorage` before React hydrates. The server has no storage and renders the default state; the browser's first render already shows the saved state. Delay loading the saved state until after hydration.

## Why the Zustand hydration error happens

`persist` hydrates the store from storage. Zustand's docs explain that with a synchronous storage such as `localStorage`, "the Zustand store will already have been hydrated at its creation" ([persisting store data](https://zustand.docs.pmnd.rs/reference/integrations/persisting-store-data)). In a server-rendered app, that means:

1. **On the server,** there is no `localStorage`, so the store keeps its initial state: `bears: 0`.
2. **In the browser,** the store is created, reads storage and holds `bears: 7` before React renders anything.
3. **During hydration,** the component renders `7` where the server HTML says `0`.

The Zustand docs list the messages you get in Next.js:

```text
Text content does not match server-rendered HTML
Hydration failed because the initial UI does not match what was rendered on the server
There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering
```

React 19 reports the same bug as "Hydration failed because the server rendered text didn't match the client". A store value used in a `className` or `style` is worse: React 19 production builds keep the server's attribute without any error.

## Fix the Zustand Next.js hydration error

All three fixes make the first client render use the same state as the server, then switch to the saved state.

### Skip hydration and rehydrate after mount

Zustand skip hydration (`skipHydration: true`) stops the store from reading storage when it is created. You call `rehydrate()` yourself, in an effect, after React has hydrated the page:

```ts title="stores/bear-store.ts"
import { create } from "zustand";
import { persist } from "zustand/middleware";

type BearState = {
  bears: number;
  addBear: () => void;
};

export const useBearStore = create<BearState>()(
  persist(
    (set) => ({
      bears: 0,
      addBear: () => set((state) => ({ bears: state.bears + 1 })),
    }),
    {
      name: "bear-storage",
      skipHydration: true,
    }
  )
);
```

```tsx title="components/store-hydration.tsx"
"use client";

import { useEffect } from "react";
import { useBearStore } from "@/stores/bear-store";

export function StoreHydration() {
  useEffect(() => {
    void useBearStore.persist.rehydrate();
  }, []);
  return null;
}
```

Render `<StoreHydration />` once, near the root, for example in `app/layout.tsx`. Every component reads the default state during hydration and re-renders with the saved state right after.

### Wait for hydration before showing stored values

If showing the default for a moment is wrong (a logged-out state for a logged-in user), render a placeholder until the store has hydrated. The Zustand docs build a hook from `onFinishHydration` and `hasHydrated`:

```tsx title="hooks/use-store-hydrated.ts"
"use client";

import { useEffect, useState } from "react";
import { useBearStore } from "@/stores/bear-store";

export function useStoreHydrated() {
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    const unsubHydrate = useBearStore.persist.onHydrate(() =>
      setHydrated(false)
    );
    const unsubFinish = useBearStore.persist.onFinishHydration(() =>
      setHydrated(true)
    );
    setHydrated(useBearStore.persist.hasHydrated());
    return () => {
      unsubHydrate();
      unsubFinish();
    };
  }, []);

  return hydrated;
}
```

```tsx title="components/bear-count.tsx"
"use client";

import { useBearStore } from "@/stores/bear-store";
import { useStoreHydrated } from "@/hooks/use-store-hydrated";

export function BearCount() {
  const bears = useBearStore((state) => state.bears);
  const hydrated = useStoreHydrated();
  return <span>{hydrated ? bears : "–"}</span>;
}
```

`hydrated` starts as `false` on the server and during hydration, so both render the placeholder.

### Read the store through a delayed hook

The Zustand docs' Next.js section suggests a small `useStore` wrapper that returns `undefined` on the first render and the store's value after an effect. It needs no change to the store, but every component has to read the store through it and handle `undefined`.

## Should the server know the value?

Every fix above shows the default first. When the value matters for the first paint (a theme, a cart count, a language), keep it where the server can read it, such as a cookie, and pass it to the page as a prop. Then the server renders the real value and nothing changes after hydration. The [storage cause guide](https://hydration.jscrate.dev/docs/causes/storage) covers the cookie approach.

## Find the pages it breaks

The ESLint plugin reads one file at a time, so it cannot see what `persist` loads from storage. `hydration-proof test` loads the page in a real browser instead. Give a [scenario](https://hydration.jscrate.dev/docs/scenarios) the saved state your users have, so the store has something to load:

```ts title="hydration-proof.config.ts"
import { defineConfig } from "hydration-proof";

export default defineConfig({
  scenarios: [
    { name: "default" },
    {
      name: "returning-user",
      localStorage: {
        "bear-storage": JSON.stringify({ state: { bears: 7 }, version: 0 }),
      },
    },
  ],
});
```

A mismatch is reported as [HP1001](https://hydration.jscrate.dev/docs/issues/hp1001) for text or [HP1004](https://hydration.jscrate.dev/docs/issues/hp1004) for a class name, with the likely cause "localStorage / sessionStorage read during render". With `--probe`, the page is loaded again without the scenario's storage; if the value changes, the cause is proven:

```bash
npx hydration-proof test --probe
```

## Related

- [localStorage hydration errors](https://hydration.jscrate.dev/docs/causes/storage)
- [useEffect and two-pass rendering](https://hydration.jscrate.dev/docs/guides/useeffect-two-pass-rendering)
- [Next.js hydration errors](https://hydration.jscrate.dev/docs/frameworks/nextjs)
- [Client-only components](https://hydration.jscrate.dev/docs/guides/client-only-component)
- [Proving a cause with probes](https://hydration.jscrate.dev/docs/probes)
