Hydration Proof

Search documentation

Find a page or section

HP3004: Two React roots generate the same ids

HP3004 (duplicate-use-id) means several React roots on one page call useId without an identifierPrefix and generate the same ids. Give each root a prefix.

HP3004 (duplicate-use-id) means two or more React roots on the same page use useId without an identifierPrefix, so each root generates the same ids. Labels and ARIA references can then point at an element in the wrong root. Give every root its own identifierPrefix, the same on the server and the client.

CodeHP3004
Nameduplicate-use-id
Default severityWarning
GroupMarkup the browser repaired
What it meansSeveral React roots on the page use useId without an identifierPrefix, so they generate the same ids and labels or ARIA references can point at the wrong element.

What the HP3004 duplicate use id finding means

useId derives an id from the component's position in its root. Two roots with the same shape start from the same position, so they produce the same ids: :R1: in React 18, «R1» in React 19.0 and 19.1, _R_1_ in React 19.2 and later.

hydration-proof reports this code when an id that looks generated by useId appears more than once and at least two hydrating roots on the page have no identifierPrefix. Any other repeated id is HP3003. The finding lists the roots without a prefix:

2 elements (<input>) have the React-generated id "_R_0_". 2 React roots on this page use useId without an identifierPrefix, so each one generates the same ids.
Roots without identifierPrefix: #header, #app.

The page still hydrates without a mismatch. The bug is what the duplicate ids connect: a <label htmlFor> in one root can focus an input in the other, and aria-describedby can read out the wrong text.

Likely causes

  • A custom server that calls hydrateRoot several times, for example for a header widget and the main app.
  • Micro-frontends or embedded widgets that each hydrate their own root.
  • Astro islands: each <astro-island> is its own React root. Astro sets an identifierPrefix for its islands, so on Astro this finding means something else hydrates without one (see Astro).

The unstable id cause covers the related problem of a prefix that differs between the server and the client.

How to fix it

Give every React root on the page its own identifierPrefix, and pass the same value to the server render and to hydrateRoot, so the ids still match during hydration:

server.tsx
import { renderToString } from "react-dom/server";
 
const headerHtml = renderToString(<Header />, { identifierPrefix: "header-" });
const appHtml = renderToString(<App />, { identifierPrefix: "app-" });
entry-client.tsx
import { hydrateRoot } from "react-dom/client";
 
hydrateRoot(document.getElementById("header")!, <Header />, {
  identifierPrefix: "header-",
});
hydrateRoot(document.getElementById("app")!, <App />, {
  identifierPrefix: "app-",
});

renderToPipeableStream and renderToReadableStream take the same identifierPrefix option.

Or render the parts in one root, for example with portals, so useId generates unique ids. Portals render on the client only, so this fits parts that do not need server HTML.

Catch it with ESLint

no-unstable-id keeps ids on useId instead of random values or counters. The collision between roots depends on how the page is assembled, so only a test of the rendered page finds it.

When it is intentional

If the duplicate ids are never referenced (no labels, no ARIA attributes), you can add an ignore.issues rule with code: "HP3004" and a reason; see ignoring findings. A prefix is still the safer choice.