Hydration Proof

Search documentation

Find a page or section

Disallow changing module-level variables while a component renders.

Changing a module-level variable during render makes the two renders disagree: the server keeps the value across every request, while each browser tab starts from the initial value. Anything built on a module-level counter (SSR id values, item numbers, render counts) drifts. no-global-render-counter reports writes to module-level let and var variables in render code.

Rulehydration-proof/no-global-render-counter
What it reportsDisallow changing module-level variables while a component renders
recommended / nextError
strictError
Server ComponentsSkipped with the next preset (they never hydrate)
SuggestionsNo
Optionsallow

What it reports

Writes (++, --, =, +=, ??=, destructuring assignments) to a module-level let or var from render code. Writes at module level, in effects and in event handlers are fine, and so is mutating an object (cache.set(...)): only reassigning the variable is reported.

A counter that ends up in an id (`field-${nextId++}`) is reported by no-unstable-id instead, with a message that points to useId().

Why a module-level counter, SSR id or render count drifts

A module is loaded once per server process and once per browser tab. On the server the variable keeps its value across every request and every user; in the browser it starts from the initial value:

server HTML (request 1,000): <p>Item #3001</p>
client render:               <p>Item #1</p>

React Strict Mode and concurrent rendering also render components more than once, so the value drifts even in the browser.

Incorrect

let renderCount = 0;
 
function Item({ name }) {
  renderCount += 1;
  return (
    <p>
      Item #{renderCount}: {name}
    </p>
  );
}
 
let lastUser;
 
function useUser(user) {
  lastUser = user;
  return lastUser;
}

Correct

function List({ items }) {
  return items.map((item, index) => (
    <p key={item.id}>
      Item #{index + 1}: {item.name}
    </p>
  ));
}
 
function useUser(user) {
  const lastUser = useRef(user);
  useEffect(() => {
    lastUser.current = user;
  }, [user]);
  return user;
}

Use useId() for ids and useRef for values that should survive re-renders of one component.

Options

OptionTypeDefaultDescription
allowstring[]Module-level variable names that may be written during render (for example a deliberate cache).
eslint.config.mjs
{
  rules: {
    'hydration-proof/no-global-render-counter': ['error', { allow: ['cache'] }],
  },
}
  • allow (string array, default []): module-level variables that may be written during render, for example a deliberate memoization cache whose value is the same on both sides.

Messages

What ESLint prints for this rule, word for word:

  • Module-level <name> is changed during render. The server keeps its value across every request while the browser starts from the initial value, so anything derived from it does not match during hydration (and React may render twice in development). Keep the value in state or a ref, or use useId() for ids.

When not to use it

When the module is only ever evaluated in the browser.