---
title: "Zustand Async Hydration — localStorage Timing Bug"
aliases: [zustand-hydration, zustand-localstorage-timing, zustand-hashydrated]
tags: [zustand, react, state-management, frontend, auth, timing, localstorage]
sources:
  - "daily/2026-04-28.md"
created: 2026-04-28
updated: 2026-04-28
---

# Zustand Async Hydration — localStorage Timing Bug

Zustand stores that use `persist` middleware hydrate from localStorage asynchronously. On the first render, the store contains its default (empty/unauthenticated) state, not the persisted state. Any component that fires an API call on mount without waiting for hydration will fire it with stale (unauthenticated) state, causing the request to fail.

## Key Points

- **Zustand persist hydration is async:** On first render the store holds initial state; localStorage data loads after the first render cycle completes
- **Symptom:** Auth-gated API calls on component mount fail (401/403/redirect) even though the user is logged in and localStorage has valid auth data
- **Fix:** Gate API calls (and route logic) behind `hasHydrated` state — don't fire until the store confirms hydration is complete
- **Common trigger:** Navigation components, layout-level data fetches, or route guards that check auth state before hydration completes
- **Affect scope:** Any Zustand store using `persist` middleware — not just auth stores

## Details

### Why Hydration Is Async

Zustand's `persist` middleware reads from `localStorage` (or another storage) during store initialization. Because `localStorage` access is technically synchronous in the browser, you might expect hydration to be immediate — but Zustand defers the hydration to ensure the store is fully set up before loading persisted state. This means on the very first synchronous render pass, the store contains only its `initialState` values.

React renders once with initial state, then Zustand hydrates, which triggers a second render with the actual persisted state. Any `useEffect(() => { ... }, [])` that runs after the first render fires before the second render, meaning it runs with stale (initial) state.

### Implementing `hasHydrated` Guard

```typescript
// store/authStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface AuthState {
  token: string | null
  user: User | null
  hasHydrated: boolean
  setHasHydrated: (state: boolean) => void
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      user: null,
      hasHydrated: false,
      setHasHydrated: (state) => set({ hasHydrated: state }),
    }),
    {
      name: 'auth-storage',
      onRehydrateStorage: () => (state) => {
        state?.setHasHydrated(true)
      },
    }
  )
)
```

```tsx
// ConversationLanding.tsx (or any component with mount-time API calls)
function ConversationLanding() {
  const { token, user, hasHydrated } = useAuthStore()

  useEffect(() => {
    if (!hasHydrated) return  // ← wait for hydration
    if (!token) {
      navigate('/login')
      return
    }
    // Safe to fire API call — auth state is real
    fetchConversations()
  }, [hasHydrated, token])

  if (!hasHydrated) {
    return <LoadingSpinner />  // or null
  }

  return <div>...</div>
}
```

### Alternative: `useStore` Hydration Hook

For cases where many components need the guard, extract it:

```typescript
// hooks/useHydration.ts
import { useEffect, useState } from 'react'
import { useAuthStore } from '@/store/authStore'

export function useHydration() {
  const hasHydrated = useAuthStore((s) => s.hasHydrated)
  return hasHydrated
}
```

Then wrap route-level components or use in layout:

```tsx
function ProtectedLayout() {
  const hydrated = useHydration()
  if (!hydrated) return <FullPageSpinner />
  return <Outlet />
}
```

### Real-World Symptom (barclays-banner-builder)

`ConversationLanding` fired an API call on mount that required auth. The Zustand auth store had not yet hydrated from localStorage, so `token` was `null` on the first render. The API call went out without an auth header → server returned 401 → component redirected to login, even though the user was already authenticated. The fix was adding `hasHydrated` as a dependency of the `useEffect` and returning early if it was `false`.

## Related Concepts

- [[wiki/concepts/pydantic-model-dict-interface]] — another silent bug pattern from the same barclays-banner-builder session
- [[wiki/client-knowledge/barclays]] — Barclays Banner Builder project context

## Sources

- [[daily/2026-04-28.md]] — barclays-banner-builder QA: ConversationLanding fetched API before auth store hydrated → route failed; fix was gating behind hasHydrated
