Hydration Proof

Search documentation

Find a page or section

Fix the React useId hydration error

An id is only stable if both sides compute it the same way.

A React useId hydration error means an id differs between the server HTML and the first browser render. Check for a counter or random value first. If you already use useId, check that both sides render the same tree and that separate React roots use separate prefixes.

Symptoms

React 18 may print both id values:

Warning: Prop `id` did not match. Server: "react-select-2-live-region" Client: "react-select-3-live-region"
Warning: Prop `htmlFor` did not match. Server: "field-5731" Client: "field-1"
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up.

An id mismatch can break a label or ARIA reference. React 19 may keep the server's id without reporting the difference in production.

hydration-proof reports a changed id as HP1002, a duplicate id as HP3003, and collisions between React roots as HP3004.

Why a React useId hydration error happens

An id must be stable across both renders. These patterns can break it:

  • A module-level counter. The server process keeps counting across every request (field-5731), while each browser tab starts at field-1.
  • Random values and the clock. Math.random(), crypto.randomUUID(), nanoid(), lodash's uniqueId and Date.now() give a new value per render.
  • useId in trees that differ. useId derives the id from the component's position in the tree, so it only matches when the server and the client render exactly the same tree above it. A component that renders something extra on one side (a typeof window branch, a client-only banner) shifts every id after it.
  • Several roots without a prefix. Two React roots on one page both start their useId sequence from the same point, so they generate the same ids.

How to fix it

Use useId instead of counters and random ids

email-field.tsx
import { useId } from "react";
 
// Before:
// let nextId = 0;
// const id = `email-${nextId++}`;
 
export function EmailField() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Email</label>
      <input id={id} type="email" />
    </>
  );
}

For list items, use stable ids from the data. Do not use useId for React key props.

Give each React root its own identifierPrefix

If a page has several React roots, give each a distinct prefix. Keep each root's prefix the same on the server and in the browser:

server.tsx
import { renderToPipeableStream } from "react-dom/server";
 
const { pipe } = renderToPipeableStream(<App />, {
  identifierPrefix: "checkout-",
});
client.tsx
import { hydrateRoot } from "react-dom/client";
 
hydrateRoot(document.getElementById("checkout")!, <App />, {
  identifierPrefix: "checkout-",
});

One root does not usually need a custom prefix.

useId hydration mismatch (App Router)

In the Next.js App Router, inspect the component tree above the changed id:

  1. Look for a Client Component above it that renders a different number of components on each side (typeof window, useMediaQuery without a server default, a mounted flag that adds siblings). Fix that component first; see browser APIs in render.
  2. useId cannot be used in async Server Components. Call it in a Client Component or a synchronous component.
  3. Keep React and Next.js on matching, current versions. Framework bugs have shifted ids before: vercel/next.js#84029 reported different useId values on each side in some Next.js 15 releases, and was closed with a fix.

Fix a Radix UI hydration error

Radix Primitives use React's useId for generated ids. A Radix id hydration error often points to a different component tree. Version combinations can matter too: radix-ui/primitives#3700 reported an id mismatch with Next.js 15.5 and React 19.2. Check compatible updates for Radix, React, and Next.js. The shadcn/ui guide covers the components built on Radix.

Fix the react-select hydration error

A react-select Next.js hydration error can come from its instance counter. If the server and browser counters differ, pass a stable instanceId:

country-select.tsx
"use client";
 
import { useId } from "react";
import Select from "react-select";
 
export function CountrySelect({
  options,
}: {
  options: { value: string; label: string }[];
}) {
  const instanceId = useId();
  return <Select instanceId={instanceId} options={options} />;
}

Use the same approach for other libraries that accept an id or prefix prop.

Catch it with ESLint

no-unstable-id flags ids built from random values, time, or counters. no-global-render-counter flags module-level counters changed during render.

npm install -D eslint-plugin-hydration-proof

Catch it in CI

hydration-proof test checks ids in production too. Use --probe to see whether a random seed changes the id. A server-side counter may change even on an identical reload. See probes.

npx hydration-proof test --probe