TLDR:
react-hooks/set-state-in-effectfires as a hard error in Next.js 16. Three of my four cases had cleaner fixes. The fourth was legitimately right to silence — but placement matters.
the wall I hit (twice, same session)
I was building features for two projects in the same week — a multi-store Shopify supply-chain and inventory-forecasting app, and a practice management system for a law firm client.
Same day. Two different components. Same lint error.
react-hooks/set-state-in-effect — a rule that eslint-config-next@16 ships as a hard error, not a warning. Not a gentle nudge. Blocks your build.
My instinct both times was the classic pattern: useEffect(() => setState(something), [something]). Hit the wall. I even tried wrapping the setState call in useCallback first, thinking that indirection would satisfy the linter.
Nope. It still fires. The rule tracks what's called, not how it's wrapped.
the three clean fixes
Once I stopped fighting it, the right tool for each case was obvious:
useDurationMode— reads fromlocalStorageto persist a days/weeks toggle. Fix:useSyncExternalStore(a React built-in that returns one value on the server, another on the client — no effect, no hydration mismatch).LotHistoryModal— fetches audit history when a prop changes. Fix: SWR, a data-fetching library. Pass the prop as the cache key, SWR handles the rest. No effect at all.- Fetch-when-user-toggles a disclosure section. Fix: move the
fetch()call directly into theonClickhandler. NouseEffect. NouseCallback. Just async function, called from the button. Embarrassingly simple once you see it.
the fourth case — and why I silenced it
On the law firm client side, I had a URL↔state sync: useSearchParams driving a selected-row state, so deep links and programmatic redirects still land users on the right record.
That's not a cascading render anti-pattern. That's cross-source sync — legitimately the documented exception case.
So I silenced it. But placement matters:
// ✅ disable above the setState call inside the effect body
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSelectedId(cur => cur === next ? cur : next) // functional-updater short-circuit
}, [next])
Not above the useEffect line. That doesn't disable anything inside the body. I left an explainer comment too — future me (or any other builder in the repo) deserves to know this wasn't laziness.
the actual lesson
The rule isn't "always fix it" or "always silence it."
It's know which bucket you're in:
- Reading browser state (localStorage, cookies,
window)? →useSyncExternalStore - Fetching on prop change? → SWR
- Fetching when user does something? → put it in the handler, full stop
- Cross-source sync (URL, external store, non-React state)? → functional-updater short-circuit +
// eslint-disable-next-lineabove the setState call
I wasted real time on case 3 before I realized there was no framework magic needed. The user clicked a button. I could just… fetch in the click handler.
The lint rule is mostly right. But it taught me more by making me argue with it than it would have if I'd just disabled it everywhere.
P.S. If you're on Next.js 16 and your build was clean yesterday and has 10+ errors today — check if
eslint-config-nextgot bumped. This rule (and a few siblings) are new defaults.