# no-unstable-id

> Use useId instead of a random id, timestamp or counter: those differ between the server render and hydration. This rule reports every unstable id.

Source: https://hydration.jscrate.dev/docs/rules/no-unstable-id
Last updated: 2026-09-18

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.

| | |
| --- | --- |
| Rule | `hydration-proof/no-unstable-id` |
| What it reports | Disallow ids built from random values, the clock or module-level counters |
| recommended / next | Error |
| strict | Error |
| Server Components | Skipped with the next preset |
| Suggestions | No |
| Options | none |

## What it reports

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

- **Sources:** everything [`no-random-in-render`](https://hydration.jscrate.dev/docs/rules/no-random-in-render)
  and [`no-date-in-render`](https://hydration.jscrate.dev/docs/rules/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:

```text
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

```jsx
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

```jsx
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.

## Related

This rule takes precedence over
[`no-random-in-render`](https://hydration.jscrate.dev/docs/rules/no-random-in-render),
[`no-date-in-render`](https://hydration.jscrate.dev/docs/rules/no-date-in-render),
[`no-global-render-counter`](https://hydration.jscrate.dev/docs/rules/no-global-render-counter) and
[`require-deterministic-list-order`](https://hydration.jscrate.dev/docs/rules/require-deterministic-list-order):
a value reported here is not reported by them.

- [Generated ids that differ](https://hydration.jscrate.dev/docs/causes/unstable-id): the cause and its
  fixes
- [Attributes didn't match](https://hydration.jscrate.dev/docs/errors/tree-hydrated-but-attributes-didnt-match),
  the warning an unstable id produces
