D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

`react-hooks/set-state-in-effect`? 27 React Compiler Lint Errors in Next 16 and Which Ones Deserve a Disable

· · 6 min read
`react-hooks/set-state-in-effect`? 27 React Compiler Lint Errors in Next 16 and Which Ones Deserve a Disable

Framework upgrades have a strange failure mode: not a single line of your code changed, the app behaves exactly like it did yesterday, and yet your terminal is suddenly full of red. That is what happened when I moved a client project onto Next 16 with React 19.2. The React Compiler lint rules came along for the ride, and one lint run gave me 27 errors.

The count was not the annoying part. The annoying part was that several of those errors pointed at code that, as far as I knew, was the correct way to write it.

The symptom: errors on code that is not wrong

The confusing ones came from three rules I had never met before:

react-hooks/set-state-in-effect
react-hooks/purity
react-hooks/incompatible-library

set-state-in-effect was the loudest. It was firing on things like this:

useEffect(() => {
  setTheme(window.localStorage.getItem("theme") ?? "system");
}, []);

That is hydrating a preference out of localStorage. The value does not exist on the server, so it genuinely cannot be read until the component mounts. Its twin got flagged too:

const [mounted, setMounted] = useState(false);
 
useEffect(() => {
  setMounted(true);
}, []);

The mount flag you use to hold back anything that must not differ between server and client. Both are canonical patterns that appear in thousands of React codebases, and both were now errors.

The 30 minutes I burned trying to "do it properly"

My first instinct was the wrong one: assume the linter is right and I am the lazy one. So I set out to rewrite that localStorage hydration "properly" with useSyncExternalStore, since in theory that is the sanctioned way to read an external source without an effect.

Thirty plus minutes disappeared into it. What I got was not cleaner code. It was longer code, harder for the next person to read, and it still needed a separate getServerSnapshot returning the same default value anyway. I had traded three obvious lines for a dozen clever ones, purely to make a rule stop talking.

That is where I stopped and changed the question. Not "how do I satisfy this rule", but "what is this rule actually protecting me from".

The root cause: three rules with three different motives

Read one at a time, they turned out not to be the same kind of problem at all.

react-hooks/set-state-in-effect is worried about double renders. Setting state inside an effect does make React render twice, and nine times out of ten that means you have derived state that should have been computed during render instead. But there is a class of cases where the second render is the whole point: the value physically does not exist until the browser is alive. localStorage hydration and mount flags live there. The rule is right about the mechanism, it just cannot see intent.

react-hooks/purity is a different animal, and a far more serious one. It fires on Date.now() or Math.random() called in the render body. This is not a false positive. A render that calls either of those is impure, its result changes every time it runs, and React Compiler cannot safely memoize anything around it. When this rule lights up, the code is genuinely wrong.

react-hooks/incompatible-library came from react-hook-form's watch(). That library works in a way the compiler cannot track, so the warning is honest, but there is nothing on my side to rewrite.

The escape hatch the linter had not learned yet

React Compiler ships a function level opt out: put the "use no memo" directive at the top of the function body and the compiler skips that component. That is exactly what the library case called for.

Except in the eslint-config-next 16.2.4 I was on, the lint rules did not recognize the directive yet. The compiler honored it, the linter did not. The emergency exit existed, but the handle was not connected.

Which left one sensible option: per line eslint-disable, with the reasoning written into the comment.

The fix: 27 to 0 in a single pass

I split the 27 into two piles.

20 real fixes. Every purity error got a lazy initializer, moving the impure computation into a function that only runs once at mount:

// before: evaluated in the render body, so every render produces a different value
const [step, setStep] = useState(Math.round((TARGET - Date.now()) / INTERVAL));
 
// after: lazy init, evaluated once when the state is created
const [step, setStep] = useState(() => Math.round((TARGET - Date.now()) / INTERVAL));

The rest was housekeeping: a handful of internal <a href> links that had never been migrated to next/link, plus a pile of deliberately unused arguments. That last group was a config problem, not a code problem:

// eslint.config.mjs
rules: {
  "no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
}

After that, arguments that only exist as positional placeholders just get an underscore prefix, and the linter stops reporting something that was never a bug.

7 disables with a reason. The remainder were the hydration and mount patterns, plus the react-hook-form watch() call I had nothing to rewrite. Those got the rule turned off one line at a time, and I made myself write down why:

useEffect(() => {
  // eslint-disable-next-line react-hooks/set-state-in-effect -- value does not exist on the server, hydration has to happen after mount
  setTheme(window.localStorage.getItem("theme") ?? "system");
}, []);

The rationale comment is not ceremony. Six months from now, a bare eslint-disable is indistinguishable from someone who gave up. With a reason attached, the next person can judge whether that reason still holds.

The rule of thumb I walked away with

The useful output of this session was not the number going from 27 to 0. It was finally being able to draw a clean line:

A new linter is not a judge. It is a new colleague who does not know the codebase yet. Sometimes it catches something you missed, sometimes it has not understood why a thing is written that way. The job is telling those two apart, not agreeing with everything.