Measured against eslint-plugin-react-hooks 7.1.1 and ESLint 9.39.5 on 2026-09-24.
Version 7 of eslint-plugin-react-hooks moved the React Compiler's diagnostics into the recommended preset. The React team's advice is to upgrade today, because "the linter does not require the compiler to be installed, so there's no risk in upgrading".1 The risk they mean is breakage. The cost they do not mention is what shows up in your terminal the first time you run it.
We measured that cost on six public React codebases: excalidraw, Outline, Mastodon, react-admin, Supabase Studio and Twenty. Five had not adopted the new rules. Across those five, the upgrade adds 1,030 new errors in 574 files, and the disable comments the projects already carry absorb exactly one of them.
The number is not the interesting part. The interesting part is what the errors are, so we drew a random sample and read every one in context:
- 53 distinct sites in application code. 3 are real bugs.
set-state-in-effect, the rule that fires most: 18 sites, 0 bugs. Most are an extra render that has a better idiom.refs, the second: 18 sites, 0 bugs, and 7 of them do not read a ref at all.immutabilityfound 2 of the 3 bugs.static-componentsfound the third.
So the new rules are mostly a performance and compiler-readiness linter that occasionally catches a real defect, and the useful question is which rule is which. The rest of this article answers that per rule, with the fix for each.
The setup
Six repositories, shallow-cloned at their default branch on 2026-09-24, one directory each so the count is the React app rather than the whole monorepo:
| Repository | Directory | Commit | TSX/JSX files | TSX/JSX lines |
|---|---|---|---|---|
| excalidraw/excalidraw | packages | 4850bf3 | 278 | 87,300 |
| outline/outline | app | 621b00f | 565 | 75,100 |
| mastodon/mastodon | app/javascript | 2b0e8b4 | 587 | 67,900 |
| marmelab/react-admin | packages | 6aeb9ed | 910 | 156,600 |
| supabase/supabase | apps/studio | 6817c48 | 2,524 | 324,700 |
| twentyhq/twenty | packages/twenty-front | 9641999 | 3,419 | 317,100 |
Mastodon already runs 7.1.1 with flat.recommended in its own config, so it is the control: what a codebase looks like after it has adopted the rules. None of the six directories compiles with the React Compiler.
Each was linted with a config that contains nothing except this plugin, so every reported error is attributable to it:
// eslint.config.mjs
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default [
{ ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts'] },
{
files: ['**/*.{js,jsx,ts,tsx,mjs,cjs}'],
languageOptions: {
parser: tseslint.parser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
linterOptions: { noInlineConfig: true },
},
reactHooks.configs.flat.recommended,
];jsnoInlineConfig: true ignores every eslint-disable comment, which gives the raw count. A second pass with it removed gives the number a team would actually see on upgrade day, with their existing suppressions still in place. The parser runs without type information, which these rules do not use.
Each codebase took between 7 and 43 seconds on a laptop, single-threaded. That is worth knowing on its own: you can get your own number in less time than it takes to read this section.
What fired
recommended in 7.1.1 enables 17 rules.2 Two are the ones you already had, rules-of-hooks and exhaustive-deps. The other fifteen are compiler diagnostics. Counting only those fifteen:
| Codebase | New errors | Files | Per 1,000 TSX/JSX lines | After existing disables |
|---|---|---|---|---|
| excalidraw | 72 | 33 | 0.82 | 72 |
| Outline | 223 | 90 | 2.97 | 223 |
| react-admin | 121 | 53 | 0.77 | 120 |
| Supabase Studio | 386 | 242 | 1.19 | 386 |
| Twenty | 228 | 156 | 0.72 | 228 |
| Mastodon (adopted) | 12 | 6 | 0.18 | 0 |
Four of the five non-adopters land between 0.7 and 1.2 errors per thousand lines. Outline is the outlier at nearly 3, and more than half of that is one rule: 121 refs errors, most of them genuine reads of .current during render.
Mastodon's 12 raw errors are exactly its 12 disable comments. That is the steady state for a large codebase that has taken the rules on: 8 suppressions of immutability, 2 of refs, 2 of set-state-in-effect, and nothing left over.
By rule, across all six:
| Rule | Errors | Codebases |
|---|---|---|
set-state-in-effect | 363 | 6 |
refs | 333 | 6 |
immutability | 105 | 6 |
static-components | 86 | 4 |
preserve-manual-memoization | 78 | 5 |
use-memo | 41 | 4 |
incompatible-library | 15 | 4 |
purity | 11 | 3 |
globals | 6 | 2 |
error-boundaries | 3 | 2 |
set-state-in-render | 1 | 1 |
The first three are 77% of the total. They are where the upgrade cost is, and they are where we sampled.
What the errors actually are
We drew a seeded random sample from the five non-adopters: 20 set-state-in-effect errors, 20 refs, 10 immutability, and afterwards 10 static-components from application code. Two refs sites were sampled twice (two errors on one line), and five sites were in tests or Storybook stories, which leaves 53 distinct sites in application code. Each was read in its file and put in one bucket:
| Rule | Sites | Real bug | Works, better idiom exists | Works by timing | Correct, flagged anyway |
|---|---|---|---|---|---|
set-state-in-effect | 18 | 0 | 14 | 0 | 4 |
refs | 18 | 0 | 5 | 6 | 7 |
immutability | 7 | 2 | 1 | 0 | 4 |
static-components | 10 | 1 | 1 | 0 | 8 |
| Total | 53 | 3 | 21 | 6 | 23 |
"Correct, flagged anyway" covers two things we kept together because the fix is the same: false positives where the rule is simply wrong about the code, and code that is correct but written in a way the compiler cannot prove. In both, the code has no defect to fix.
Three in 53 is a small sample, and it is not a rate you should project onto your own code. What it does support is a ranking: which rules spend your attention on bugs and which spend it on style.
set-state-in-effect: 363 errors, no bugs, one extra render each
The rule flags a setState call that runs synchronously in an effect body. The React docs file it under performance: "Calling setState synchronously within an effect can trigger cascading renders", and the invalid examples are deriving state from props and transforming data in an effect.3
Every one of the 18 sites we read works. Fourteen of them are the same three shapes:
- Resetting state when something changes (6 sites). A dialog clears its form when it opens, a selection clears when the search query changes.
- Copying a prop or fetched value into local state (5). A form resets its values when the config it edits reloads.
- Picking a default once data arrives (3). The first tab becomes active when the list of tabs loads.
Each one renders, commits, runs the effect, sets state and renders again. The user never sees the first render's wrong value in most cases, which is why nobody noticed, and it is also why the rule is right that the effect is unnecessary. The React docs have spelled out the replacements for years:4
// Flagged: the default is copied into state once the list arrives
const [active, setActive] = useState<string>();
useEffect(() => {
setActive(fields[0]);
}, [fields]);
// Clean: store only what the user chose, derive the rest during render
const [picked, setPicked] = useState<string>();
const active = picked ?? fields[0];tsxFor "reset everything when X changes", give the component a key of X at the call site and React throws the old state away for you. For "adjust one piece of state when a prop changes", the docs' pattern of comparing against a stored previous value during render passes the linter; we checked:
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}tsxThe four sites that are correct as written are the ones that react to something outside React: a latch that stops an IntersectionObserver from triggering the same page fetch twice, a mounted flag that defers a random choice until after hydration, a status flag set after notifying a parent. The docs themselves allow setState in an effect when the value comes from an external system or a DOM measurement.3
Two things the rule does not flag, which are worth knowing before you start rewriting: a setState after an await inside the effect (the usual fetch-then-set shape) is fine, because it is not synchronous, and useLayoutEffect is treated the same as useEffect.
What to do: fix these, but as refactoring, not as bug-fixing. They are cheap, each one deletes an effect, and none of them is urgent.
refs: a third of them never read a ref
The rule flags reading or writing ref.current during render.5 On paper that is a narrow check. In practice it was the noisiest rule we ran, and we could reproduce why with a handful of three-line files.
Any ref={something.member} is flagged. floating-ui's useFloating hands you refs.setReference, and passing it to a ref prop the documented way is an error:
const { refs } = useFloating();
return <div ref={refs.setReference} />; // flaggedtsxRenaming refs does not help. A module-level plain object passed the same way is also flagged. Destructure the member first and the error goes away, with no behavioural change:
const { refs } = useFloating();
const { setReference } = refs;
return <div ref={setReference} />; // cleantsxA ref passed through props contaminates the other props. <canvas ref={props.canvasRef} onClick={props.onClick} /> reports two errors, one of them on onClick, which is not a ref and is not read. Destructure the props and both disappear.
The documented lazy-initialisation exception is literal. The docs allow if (ref.current === null) { ref.current = make(); }.5 if (!ref.current) is flagged, and so is any read of ref.current afterwards, which is the reason people lazily initialise a ref in the first place. If you need the value during render, useState(() => make()) is clean.
Across the five non-adopters, 106 of 331 refs errors (32%) are on a line that contains no .current at all. That is not a precise false-positive rate, since a ref object can be passed without .current, but it matches what the sample showed: 7 of 18 sites had no ref read to fix.
The other 11 sample sites were real ref reads:
- Six read a DOM ref during render and work because of timing. A settings form computes
formRef.current?.checkValidity()in the body to decide whether Save is enabled. On the first render the ref isnull, so the value isundefined, and it only becomes right once something else causes a re-render. Nothing is broken today; each of these is one refactor away from being broken. - Five are idioms the rule rejects on purpose. The "latest callback" pattern (
callbackRef.current = callbackin the body) is the most common; its replacement isuseEffectEvent, which the linter accepts.useRef(new Date()).currentfor a stable timestamp belongs inuseState(() => new Date()).
What to do: clear the mechanical false positives first, by destructuring, because they are a third of the count and cost nothing. Then treat the DOM reads during render as the real work, since those are the ones that fail later.
immutability: the one rule that found bugs
immutability flags mutation of props, state and other values React treats as snapshots.6 It had the lowest volume of the three big rules and the highest yield. Both of its real bugs had the same shape, and the shape is worth learning:
function LoginBackground({ src }: { src: string }) {
let loaded = false; // runs again on every render
const markLoaded = () => {
loaded = true;
};
useEffect(() => {
if (!loaded) {
const img = new Image();
img.onload = markLoaded;
img.src = src;
}
}); // no dependency array: runs after every render
}tsxThat is a simplified version of the pattern we found in a login page. The intent is "load the background image once". The let is recreated on every render, so the guard is false every time the effect runs, and the image is requested again after every render. The second instance was the same idea in a different place: a guard meant to stop an auth callback from being handled twice, declared with let in the hook body and assigned inside a data-fetching library's query function, where it resets on every render too.
The fix is useRef for a value that must survive renders, or moving the variable inside the effect if it does not need to.
Here is the part that makes v7 worth running even if you ignore everything else in it. The old exhaustive-deps rule already catches this shape when the assignment is written directly inside the effect, with the message "Assignments to the 'loaded' variable from inside React Hook useEffect will be lost after each render." Both real instances had moved the assignment one call away, into a helper function and into a callback passed to a library, and v5 saw neither. v7 caught both.
The other five sites were not bugs. Two mutate a MobX model, which is mutable by design; if your state layer is MobX, expect immutability to be loud (it accounts for 23 of Outline's errors). One sets width and height on a canvas element inside an effect, which is exactly where imperative DOM code belongs. One assigns a debug global in development only. One is a function used in an effect before the line that declares it, which cannot fail at runtime because the effect runs after render.
Mastodon, the adopted codebase, spends 8 of its 12 suppressions on this rule, three of them on callback refs that assign to a ref passed in as an argument. They cite an open React issue for the case, filed as a false positive in October 2025.7 So there is a known false positive here, and it is narrow.
What to do: keep this rule at error, read every hit, and suppress the MobX and callback-ref cases individually with a reason. It is the rule most likely to be pointing at something that is actually broken.
static-components: mostly icons, once a real one
static-components flags components created during render; its error message explains that "components created during render will reset their state each time they are created". Sixty of its 86 errors were in application code. Of the 10 we sampled:
- Eight were a component chosen at render from a fixed set. Seven were icon lookups,
const Icon = getIcon(name)followed by<Icon />, and one picked between'button',Link,'a'and'div'. The chosen components are defined once at module level, so nothing remounts, but the compiler cannot prove that. - One was a small stateless component defined inside another. It does remount on every render; it has no state to lose.
- One was a real bug. A table row called
motion.create(TableRow)in its body, which creates a new component type on every render, so the row remounts and replays its enter animation whenever its props change. Motion's own documentation warns against exactly this: "Make sure not to callmotion.create()within a React render function! This will make a new component every render, breaking your animations."8
What to do: move anything created with a factory (motion.create, styled(...), forwardRef wrappers) to module scope; that is where this rule's bugs are. For registry lookups that return stable components, the lookup is the false positive, not the component.
The long tail
We did not classify the remaining 155 errors by hand, so this is what the rules report rather than a verdict on them:
preserve-manual-memoization(78) andincompatible-library(15) say "Compilation Skipped". They are the compiler reporting that it would leave a component alone, not that the component is wrong. If you are not running the compiler, they describe an optimisation you are not getting anyway.use-memo(41) is almost entirely about the shape of the dependency list: not an array literal, or not "simple expressions (e.g.x,x.y.z,x?.y?.z)". Pulling a computed dependency into a variable fixes most of them.purity(11) flags known-impure calls such asDate.now()andMath.random()during render, which is a correct complaint and is usually a one-line move into state or an effect.
What to do, in order
- Run it before you commit to it. Add the plugin at 7.x in a branch with the config above and get your own table. It takes under a minute per codebase at these sizes.
- Set
preserve-manual-memoizationandincompatible-librarytooffif you are not adopting the compiler. They report skipped compilation, which is not information you can act on without it. - Destructure your way through the
refsfalse positives.ref={obj.member}andprops.xRefaccount for about a third of that rule's errors and the fix is mechanical. - Read every
immutabilityhit and everystatic-componentshit in application code. Between them they held all three bugs we found. Search forletin component bodies and factory calls likemotion.createwhile you are there. - Treat
set-state-in-effectas a refactoring backlog. Each fix deletes an effect and a render. None of the 18 we read was urgent. - Turn the rest on at
warnand ratchet. The React docs say plainly that you "don't need to fix all violations immediately", because the compiler skips a component it cannot prove safe rather than breaking it.2
Where this is weak
- Six codebases is not the ecosystem. They are large, maintained TypeScript apps. A codebase built on MobX will look like Outline; one built on a component registry will look like Twenty. The per-thousand-lines rates are a range to compare against, not a prediction.
- The sample is 53 sites out of about 1,000. Three bugs in 53 says the bug-finding rate is low. It does not say it is 6%, and it does not say the long tail is bug-free.
- Classification is judgement. "Works by timing" and "works, better idiom exists" are calls a reader could make differently. The one category that should not move is "real bug": each of the three produces observable wrong behaviour from the code as written.
- The parser had no type information. These rules do not use it, but a project's own config may lint a different file set than a single directory does.
- We measured one plugin version on one day. The canary line publishes almost daily, and a false positive fixed upstream next month changes the
refscolumn most.
FAQ
Does eslint-plugin-react-hooks 7 require the React Compiler?
No. The React team's release notes say the linter does not require the compiler to be installed.1 The rules run the compiler's analysis inside ESLint and report what it finds; nothing is compiled.
Why do I suddenly have hundreds of set-state-in-effect errors?
Because version 7 added the rule to recommended, and synchronous setState in an effect is one of the most common patterns in React code written before it. On our six codebases it was the most frequent new error, at 363. In our sample none of them were bugs; they were extra renders with a documented replacement: derive the value during render, reset with key, or set it in the event handler that changed the input.
How do I fix "Cannot access refs during render" on floating-ui's refs.setReference?
Destructure it before passing it: const { setReference } = refs; then ref={setReference}. The rule flags any member expression passed to a ref prop, even from a plain object, and a destructured identifier is not flagged. Behaviour is unchanged.
Is setState after a fetch in useEffect flagged?
No. set-state-in-effect flags synchronous calls. A setState that runs after an await or in a .then() callback inside the effect is not reported.
Which of the new rules is most likely to find a real bug?
On our sample, immutability and static-components: 2 bugs in 7 application sites and 1 in 10. set-state-in-effect and refs found none in 18 sites each. The recurring bug shape was a let in a component body used as a guard across renders, which resets on every render.
Can I adopt the new rules gradually?
Yes, and the React docs recommend it: a component the compiler cannot prove safe is skipped, not broken, so you "don't need to fix all violations immediately".2 Mastodon, the one codebase in our set that had adopted the preset, carries 12 suppressions across 68,000 lines of TSX and JSX.
Sources
Checked 2026-09-24.
Error counts, rates and the sample classification are our own measurement, made with the config above against eslint-plugin-react-hooks 7.1.1 and ESLint 9.39.5 on the commits listed, on 2026-09-24.
Sources
-
React blog: React Compiler v1.0 - that React Compiler's lint rules ship in
eslint-plugin-react-hooks, that "the linter does not require the compiler to be installed, so there's no risk in upgrading eslint-plugin-react-hooks", that the React team recommends everyone upgrade, and that therecommendedpreset is how to enable the compiler rules. -
React docs: eslint-plugin-react-hooks - the list of rules in the
recommendedpreset; that the lints coverexhaustive-depsandrules-of-hooksplus issues flagged by React Compiler and "can be used even if your app hasn't adopted the compiler yet"; and that when the compiler reports a diagnostic it skips that component, so "you don't need to fix all violations immediately". -
React docs: set-state-in-effect - that the rule flags synchronous
setStatein an effect as a cause of extra renders, that its invalid examples include deriving state from props and transforming data in an effect, and that setState in an effect is valid when the value comes from a ref or an external system. -
React docs: You Might Not Need an Effect - resetting all state when a prop changes by passing a
key, adjusting some state by comparing against a stored previous value during render, calculating derived values during render, and that an effect which immediately updates state "restarts the whole process from scratch". -
React docs: refs - that the rule flags reading and writing
ref.currentduring render, and that lazy initialisation written asif (ref.current === null)is allowed. -
React docs: immutability - that the rule validates against mutating props, state and other immutable values, with examples of in-place array and object mutation.
-
facebook/react issue #34955, "react-hooks/immutability false positive on ref assignment" - a report, open since 2025-10-23, that assigning to a ref's
currentinside a callback ref forwarded through a custom hook is flagged byimmutability. -
Motion docs: motion component, custom components - the warning not to call
motion.create()within a React render function because it makes a new component every render and breaks animations.