facebook / react · Issue No. 37177
Self-contained and ESLint-only — no app or Playground needed, since the rule is what is under test.
repro.jsx:
import { useEffect, useReducer, useState } from 'react';
function reducer(state, action) {
return action.type === 'inc' ? state + 1 : state;
}
// A: useState setter in an effect body — reported.
export function WithUseState({ flag }) {
const [n, setN] = useState(0);
useEffect(() => {
if (flag) setN(1);
}, [flag]);
return <div>{n}</div>;
}
// B: useReducer dispatch in an effect body — NOT reported.
export function WithUseReducer({ flag }) {
const [n, dispatch] = useReducer(reducer, 0);
useEffect(() => {
if (flag) dispatch({ type: 'inc' });
}, [flag]);
return <div>{n}</div>;
}
eslint.config.js:
import reactHooks from 'eslint-plugin-react-hooks';
export default [{
files: ['**/*.jsx'],
plugins: { 'react-hooks': reactHooks },
languageOptions: {
parserOptions: { ecmaFeatures: { jsx: true }, ecmaVersion: 'latest', sourceType: 'module' },
},
rules: { 'react-hooks/set-state-in-effect': 'error' },
}];
npx eslint repro.jsx
Actual output — one finding, for WithUseState only:
line 12: [react-hooks/set-state-in-effect] Calling setState synchronously within an effect can trigger cascading renders
Expected: two findings, or an explanation of why B is exempt.
Every time.
19.2.0
eslint-plugin-react-hooks@7.0.1 (ESLint 9.39.2, flat config, Node 22)
Both components cause the same thing: a synchronous state update inside an effect body, so React renders, commits, runs the effect, and immediately re-renders. The rule's own description applies to B word for word —
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended.
— and useReducer is not an escape from cascading renders, only from a setter. So a codebase can silence this rule wholesale by converting useState/setState to useReducer/dispatch, with no change to render behaviour and no diagnostic. In review that reads as a tidy-up, which is what makes it easy to do by accident.
It looks deliberate in the type system but perhaps not in this validation. From the published bundle, the compiler models the two as distinct function shapes:
function isSetStateType(id) { return id.type.kind === 'Function' && id.type.shapeId === 'BuiltInSetState'; }
function isDispatcherType(id) { return id.type.kind === 'Function' && id.type.shapeId === 'BuiltInDispatch'; }
validateNoSetStateInEffects checks only isSetStateType. A broader predicate that already covers both exists and is used elsewhere:
function isStableType(id) {
return (isSetStateType(id) || isSetActionStateType(id) ||
isDispatcherType(id) || isUseRefType(id) || isStartTransitionType(id));
}
So the information is present; the validation just does not consult it. If excluding dispatch is intentional — for instance because reducers are the recommended escape hatch, which exhaustive-deps messages actively suggest ("you can also switch to useReducer instead of useState and read '…' in the reducer") — it would be worth saying so in the rule's docs, because the current behaviour reads as an oversight to anyone measuring their codebase against it.
Related: #35390 reports the same shape via useEffectEvent (a legitimate API that launders a setState past the rule), and #35029 / #35910 report other false negatives. If those are bugs, this looks like one too.
I found this while taking an app's count of this rule to zero. The rule reported 0 findings; a small AST scan for dispatch(...) directly in an effect body found 9 genuine ones, all in context providers sitting above the whole UI. The number was accurate about the rule and wrong about the code.
Happy to open a PR adding isDispatcherType to that validation, plus fixtures, if you would take it — I did not want to assume the scope was accidental.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.