Skip to content

useLocationState: hydration mismatch when the reader hydrates inside a Suspense boundary #992

Closed

Description

@ivogt

Problem

useLocationState reads history.state in its useState initializer once <html> has data-hydrated (src/browser/react/location-state.ts:26-31, :76-82). The root sets that attribute from an effect after it hydrates (src/browser/rsc-router.tsx:699).

A reader inside a Suspense boundary that hydrates after that effect runs the initializer during its own hydration. hasHydrated() is already true there, so it reads the stored value and renders it. The server rendered undefined, so React reports a hydration mismatch and client-renders the boundary.

The deferred first read (initialReadDeferredRef) handles readers that hydrate with the root. It doesn't cover readers that hydrate later, which in an RSC app is most per-page UI under a <Suspense>.

Seen on 0.18.0 with React 19.2.

Repro (sketch)

// grid-state.ts
export const GridState = createLocationState<{ count: number }>();

// grid.tsx
"use client";
export function Grid() {
  const state = useLocationState(GridState);
  return <p>{state?.count ?? 0}</p>;
}

// page (server)
<Suspense fallback={<p>…</p>}>
  <Grid />
</Suspense>
  1. Soft-navigate to the page with router.push(url, { state: [GridState({ count: 3 })] }).
  2. Hard-reload the page. The history entry keeps its state.
  3. The server renders 0. The root hydrates and sets data-hydrated. The boundary then hydrates, the initializer reads 3, and React reports "Hydration failed because the server rendered text didn't match the client".

Expected

The first client render of any hydrating reader matches the server (undefined), and the reader updates to the stored value right after, as it already does for readers that hydrate with the root.

Suggested fix

Read through useSyncExternalStore(subscribe, getSnapshot, () => undefined). React uses the server snapshot for whichever render hydrates the component, wherever it sits, then re-renders with the client snapshot. The flash variant's read-once / clear-after-paint logic would stay in the effect. getSnapshot needs a stable reference for an unchanged entry, which history.state[key] already gives.

Workaround

The reader returns undefined while it is hydrating, using a local useSyncExternalStore(noop, () => false, () => true) flag, and the stored value after that.

Activity

  1. added 4 commits that reference this issue on Oct 3, 2026
    0d32abe
    666da4b
    ffe3e00
    fcc44a0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions