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.
| Rule | hydration-proof/no-global-render-counter |
|---|---|
| What it reports | Disallow changing module-level variables while a component renders |
| recommended / next | Error |
| strict | Error |
| Server Components | Skipped with the next preset (they never hydrate) |
| Suggestions | No |
| Options | allow |
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
| Option | Type | Default | Description |
|---|---|---|---|
| allow | string[] | — | Module-level variable names that may be written during render (for example a deliberate cache). |
{
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.
Related
no-unstable-idreports counters that are used for ids. Those writes are not reported by this rule.- Generated ids that differ: why counters break ids, and the fix
- Text content does not match server-rendered HTML, the error a drifting count produces