Hydration Proof

Search documentation

Find a page or section

Fix the Math.random hydration error

Every render rolls the dice again, and hydration is a render.

A Math.random hydration error happens when a component generates a random value while it renders. The server rolls one number, the browser rolls another during hydration, and the HTML no longer matches. Generate the value once on the server and pass it down, use useId for element ids, or pick the value in an effect after hydration.

Symptoms

Hydration failed because the server rendered text didn't match the client.
Text content does not match server-rendered HTML.
Warning: Text content did not match. Server: "Lucky number 0.72810394" Client: "Lucky number 0.19420517"
Minified React error #418; visit https://react.dev/errors/418

A crypto.randomUUID hydration mismatch works the same way, with a UUID in place of the decimal. In an attribute (className, style, data-*) the result is worse: React 19 keeps the server's value in production without reporting it, so the page silently runs with the wrong attribute.

hydration-proof reports text as HP1001 and attributes as HP1002, with the cause Random value. It recognizes random-looking decimals and UUIDs on both sides; in the package's test suite it names the cause with 94% confidence for Math.random() and 89% for crypto.randomUUID().

Why the Math.random hydration error happens

Hydration is a render. Anything random that runs during render runs twice, once on the server and once in the browser, and the two results differ:

  • Math.random()
  • crypto.randomUUID() and crypto.getRandomValues(), and randomUUID, randomBytes and randomInt from node:crypto
  • v1, v4, v6 and v7 from uuid (v3 and v5 are deterministic)
  • nanoid(), and generators made with customAlphabet() or customRandom()
  • uniqueId, random and sample from lodash

A state initializer does not help: useState(() => Math.random()) runs on the server and again during hydration.

How to fix it

Pick the value on the server and pass it down

A Server Component renders once, so a random choice there is made once and sent to the client as a prop:

app/tips/page.tsx
import { Tip } from "./tip";
 
// A Server Component: the choice is made once, on the server.
export default async function Page() {
  const tips = await getTips();
  const tip = tips[Math.floor(Math.random() * tips.length)];
  return <Tip tip={tip} />;
}
app/tips/tip.tsx
"use client";
 
export function Tip({ tip }: { tip: string }) {
  return <p>{tip}</p>;
}

For many random values, send a seed instead and use a seeded generator on both sides: the same seed produces the same sequence.

Use useId for element ids

Random ids for id, htmlFor and aria-* attributes are the most common source of this bug. React's useId returns the same id on the server and during hydration:

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

Generated id mismatches covers counters, several React roots and component libraries.

Randomize after hydration

When the value should change on every visit and nothing depends on it before the page is interactive, start from a stable value and randomize in an effect:

shuffle-tip.tsx
"use client";
 
import { useEffect, useState } from "react";
 
// A Math.random hydration error, Next.js or any SSR app: never roll the dice in render.
export function ShuffleTip({ tips }: { tips: string[] }) {
  const [tip, setTip] = useState(tips[0]);
 
  useEffect(() => {
    setTip(tips[Math.floor(Math.random() * tips.length)]);
  }, [tips]);
 
  return <p>{tip}</p>;
}

Event handlers run after hydration too, so a crypto.randomUUID() inside onClick or onSubmit is fine.

Catch it with ESLint

no-random-in-render reports every call in the list above in render code, and skips Server Components with the next preset. Random values that end up in ids are reported by no-unstable-id instead, with a message that points at useId.

npm install -D eslint-plugin-hydration-proof

Catch it in CI

hydration-proof test reports each random value as HP1001 or HP1002. With --probe, it reloads the page with a different seed for Math.random() and crypto.getRandomValues() in the browser. If the client value changes with only the seed changed, randomness is the proven cause. The server keeps its real random values, so fixing the seed never hides the bug. See probes.

npx hydration-proof test --probe