Hydration Proof

Search documentation

Find a page or section

Disallow ids built from random values, the clock or module-level counters.

Use useId instead of a random id, a timestamp or a module-level counter: those values differ between the server render and hydration, so id, htmlFor and aria-* references stop matching. no-unstable-id reports every value that changes between renders and ends up in an id, at the call that makes it unstable.

Rulehydration-proof/no-unstable-id
What it reportsDisallow ids built from random values, the clock or module-level counters
recommended / nextError
strictError
Server ComponentsSkipped with the next preset (they never hydrate)
SuggestionsNo
Optionsnone

What it reports

Values that change between renders and end up in an id:

  • Sources: everything no-random-in-render and no-date-in-render recognize (Math.random(), crypto.randomUUID(), uuid, nanoid, lodash's uniqueId, Date.now(), new Date(), ...), and module-level let/var counters changed during render (nextId++).
  • Id attributes: id, htmlFor, for, aria-labelledby, aria-describedby, aria-controls, aria-owns, aria-activedescendant, aria-details, aria-errormessage, list and popoverTarget on any element; name on input, select, textarea, button, fieldset and output; and props ending in Id on components (labelId, triggerId).
  • Id state: useState and useRef whose variable is named like an id (id, inputId, idRef).

The value is followed through local variables, useState, useRef and useMemo initializers, template literals, class fields (this.id) and module-level constants. The report is on the call (or counter update) that makes the value unstable.

Why use useId instead of a random id

The id rendered on the server is not the id rendered during hydration:

server HTML:   <label for="field-0.4211">Email</label><input id="field-0.4211">
client render: <label for="field-0.8390">Email</label><input id="field-0.8390">

React keeps the server's attributes without a warning in production, so the page works until another render updates some attributes but not others. Labels then point at nothing and screen readers lose the connection. A module-level counter is worse: the server keeps counting across requests (field-5731) while every browser starts at field-1.

useId() produces the same id on the server and during hydration, because it is derived from the component's position in the tree.

Incorrect

import { nanoid } from "nanoid";
 
let nextId = 0;
 
function EmailField() {
  const id = `email-${nextId++}`;
  return (
    <>
      <label htmlFor={id}>Email</label>
      <input id={id} type="email" />
    </>
  );
}
 
function Tooltip({ children }) {
  const [tooltipId] = useState(() => nanoid());
  return <span aria-describedby={tooltipId}>{children}</span>;
}

Correct

function EmailField() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Email</label>
      <input id={id} type="email" />
    </>
  );
}
 
function Tooltip({ children }) {
  const tooltipId = useId();
  return <span aria-describedby={tooltipId}>{children}</span>;
}

For list items, combine useId() with a stable key from your data: `${id}-${item.id}`.

Options

This rule has no options.

Messages

What ESLint prints for this rule, word for word:

  • <source> makes <sink> differ between the server render and hydration, so the id and every reference to it (labels, ARIA attributes) do not match. Use React useId() to create ids.

When not to use it

In components that are never server-rendered. Otherwise there is no reason to turn it off: useId() exists in React 18 and 19.

This rule takes precedence over no-random-in-render, no-date-in-render, no-global-render-counter and require-deterministic-list-order: a value reported here is not reported by them.