@samitouri / QOS-React / commits / 3640f38a72

[compiler] Add enableVerboseNoSetStateInEffect to suggest options to user/agent (#35306)

The current `validateNoSetStateInEffects` error has potential false positives because we cannot fully statically detect patterns where calling setState in an effect is actually valid. This flag `enableVerboseNoSetStateInEffect` adds a verbose error mode that presents multiple possible use-cases, allowing an agent to reason about which fix is appropriate before acting: 1. Non-local derived data - suggests restructuring state ownership 2. Derived event pattern - suggests requesting an event callback from parent 3. Force update / external sync - suggests using `useSyncExternalStore` This gives agents the context needed to make informed decisions rather than blindly applying a fix that may not be correct for the specific situation.

lauren committed Dec 8, 2025 at 09:16 UTC 3640f38a728f3a057649cf7aec65a6ce14c2eac0
9 files changed +365 -21
compiler/packages/babel-plugin-react-compiler/src/CompilerError.ts
+3 -1
@@ -869,7 +869,9 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
869 severity: ErrorSeverity.Error,
870 name: 'set-state-in-effect',
871 description:
872 - 'Validates against calling setState synchronously in an effect, which can lead to re-renders that degrade performance',
872 + 'Validates against calling setState synchronously in an effect. ' +
873 + 'This can indicate non-local derived data, a derived event pattern, or ' +
874 + 'improper external data synchronization.',
875 preset: LintRulePreset.Recommended,
876 };
877 }
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+10
@@ -700,6 +700,16 @@ export const EnvironmentConfigSchema = z.object({
700 */
701 enableAllowSetStateFromRefsInEffects: z.boolean().default(true),
702
703 + /**
704 + * When enabled, provides verbose error messages for setState calls within effects,
705 + * presenting multiple possible fixes to the user/agent since we cannot statically
706 + * determine which specific use-case applies:
707 + * 1. Non-local derived data - requires restructuring state ownership
708 + * 2. Derived event pattern - detecting when a prop changes
709 + * 3. Force update / external sync - should use useSyncExternalStore
710 + */
711 + enableVerboseNoSetStateInEffect: z.boolean().default(false),
712 +
713 /**
714 * Enables inference of event handler types for JSX props on built-in DOM elements.
715 * When enabled, functions passed to event handler props (props starting with "on")
compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts
+52 -20
@@ -121,26 +121,58 @@ export function validateNoSetStateInEffects(
121 if (arg !== undefined && arg.kind === 'Identifier') {
122 const setState = setStateFunctions.get(arg.identifier.id);
123 if (setState !== undefined) {
124 - errors.pushDiagnostic(
125 - CompilerDiagnostic.create({
126 - category: ErrorCategory.EffectSetState,
127 - reason:
128 - 'Calling setState synchronously within an effect can trigger cascading renders',
129 - description:
130 - 'Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. ' +
131 - 'In general, the body of an effect should do one or both of the following:\n' +
132 - '* Update external systems with the latest state from React.\n' +
133 - '* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\n' +
134 - 'Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. ' +
135 - '(https://react.dev/learn/you-might-not-need-an-effect)',
136 - suggestions: null,
137 - }).withDetails({
138 - kind: 'error',
139 - loc: setState.loc,
140 - message:
141 - 'Avoid calling setState() directly within an effect',
142 - }),
143 - );
124 + const enableVerbose =
125 + env.config.enableVerboseNoSetStateInEffect;
126 + if (enableVerbose) {
127 + errors.pushDiagnostic(
128 + CompilerDiagnostic.create({
129 + category: ErrorCategory.EffectSetState,
130 + reason:
131 + 'Calling setState synchronously within an effect can trigger cascading renders',
132 + description:
133 + 'Effects are intended to synchronize state between React and external systems. ' +
134 + 'Calling setState synchronously causes cascading renders that hurt performance.\n\n' +
135 + 'This pattern may indicate one of several issues:\n\n' +
136 + '**1. Non-local derived data**: If the value being set could be computed from props/state ' +
137 + 'but requires data from a parent component, consider restructuring state ownership so the ' +
138 + 'derivation can happen during render in the component that owns the relevant state.\n\n' +
139 + "**2. Derived event pattern**: If you're detecting when a prop changes (e.g., `isPlaying` " +
140 + 'transitioning from false to true), this often indicates the parent should provide an event ' +
141 + 'callback (like `onPlay`) instead of just the current state. Request access to the original event.\n\n' +
142 + "**3. Force update / external sync**: If you're forcing a re-render to sync with an external " +
143 + 'data source (mutable values outside React), use `useSyncExternalStore` to properly subscribe ' +
144 + 'to external state changes.\n\n' +
145 + 'See: https://react.dev/learn/you-might-not-need-an-effect',
146 + suggestions: null,
147 + }).withDetails({
148 + kind: 'error',
149 + loc: setState.loc,
150 + message:
151 + 'Avoid calling setState() directly within an effect',
152 + }),
153 + );
154 + } else {
155 + errors.pushDiagnostic(
156 + CompilerDiagnostic.create({
157 + category: ErrorCategory.EffectSetState,
158 + reason:
159 + 'Calling setState synchronously within an effect can trigger cascading renders',
160 + description:
161 + 'Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. ' +
162 + 'In general, the body of an effect should do one or both of the following:\n' +
163 + '* Update external systems with the latest state from React.\n' +
164 + '* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\n' +
165 + 'Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. ' +
166 + '(https://react.dev/learn/you-might-not-need-an-effect)',
167 + suggestions: null,
168 + }).withDetails({
169 + kind: 'error',
170 + loc: setState.loc,
171 + message:
172 + 'Avoid calling setState() directly within an effect',
173 + }),
174 + );
175 + }
176 }
177 }
178 }
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-derived-event.expect.md new
+74
@@ -0,0 +1,74 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect
6 +import {useState, useEffect} from 'react';
7 +
8 +function VideoPlayer({isPlaying}) {
9 + const [wasPlaying, setWasPlaying] = useState(isPlaying);
10 + useEffect(() => {
11 + if (isPlaying !== wasPlaying) {
12 + setWasPlaying(isPlaying);
13 + console.log('Play state changed!');
14 + }
15 + }, [isPlaying, wasPlaying]);
16 + return <video />;
17 +}
18 +
19 +export const FIXTURE_ENTRYPOINT = {
20 + fn: VideoPlayer,
21 + params: [{isPlaying: true}],
22 +};
23 +
24 +```
25 +
26 +## Code
27 +
28 +```javascript
29 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect
30 +import { useState, useEffect } from "react";
31 +
32 +function VideoPlayer(t0) {
33 + const $ = _c(5);
34 + const { isPlaying } = t0;
35 + const [wasPlaying, setWasPlaying] = useState(isPlaying);
36 + let t1;
37 + let t2;
38 + if ($[0] !== isPlaying || $[1] !== wasPlaying) {
39 + t1 = () => {
40 + if (isPlaying !== wasPlaying) {
41 + setWasPlaying(isPlaying);
42 + console.log("Play state changed!");
43 + }
44 + };
45 +
46 + t2 = [isPlaying, wasPlaying];
47 + $[0] = isPlaying;
48 + $[1] = wasPlaying;
49 + $[2] = t1;
50 + $[3] = t2;
51 + } else {
52 + t1 = $[2];
53 + t2 = $[3];
54 + }
55 + useEffect(t1, t2);
56 + let t3;
57 + if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
58 + t3 = <video />;
59 + $[4] = t3;
60 + } else {
61 + t3 = $[4];
62 + }
63 + return t3;
64 +}
65 +
66 +export const FIXTURE_ENTRYPOINT = {
67 + fn: VideoPlayer,
68 + params: [{ isPlaying: true }],
69 +};
70 +
71 +```
72 +
73 +### Eval output
74 +(kind: ok) <video></video>
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-derived-event.js new
+18
@@ -0,0 +1,18 @@
1 +// @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect
2 +import {useState, useEffect} from 'react';
3 +
4 +function VideoPlayer({isPlaying}) {
5 + const [wasPlaying, setWasPlaying] = useState(isPlaying);
6 + useEffect(() => {
7 + if (isPlaying !== wasPlaying) {
8 + setWasPlaying(isPlaying);
9 + console.log('Play state changed!');
10 + }
11 + }, [isPlaying, wasPlaying]);
12 + return <video />;
13 +}
14 +
15 +export const FIXTURE_ENTRYPOINT = {
16 + fn: VideoPlayer,
17 + params: [{isPlaying: true}],
18 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-force-update.expect.md new
+97
@@ -0,0 +1,97 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect
6 +import {useState, useEffect} from 'react';
7 +
8 +const externalStore = {
9 + value: 0,
10 + subscribe(callback) {
11 + return () => {};
12 + },
13 + getValue() {
14 + return this.value;
15 + },
16 +};
17 +
18 +function ExternalDataComponent() {
19 + const [, forceUpdate] = useState({});
20 + useEffect(() => {
21 + const unsubscribe = externalStore.subscribe(() => {
22 + forceUpdate({});
23 + });
24 + return unsubscribe;
25 + }, []);
26 + return <div>{externalStore.getValue()}</div>;
27 +}
28 +
29 +export const FIXTURE_ENTRYPOINT = {
30 + fn: ExternalDataComponent,
31 + params: [],
32 +};
33 +
34 +```
35 +
36 +## Code
37 +
38 +```javascript
39 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect
40 +import { useState, useEffect } from "react";
41 +
42 +const externalStore = {
43 + value: 0,
44 + subscribe(callback) {
45 + return () => {};
46 + },
47 + getValue() {
48 + return this.value;
49 + },
50 +};
51 +
52 +function ExternalDataComponent() {
53 + const $ = _c(4);
54 + let t0;
55 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
56 + t0 = {};
57 + $[0] = t0;
58 + } else {
59 + t0 = $[0];
60 + }
61 + const [, forceUpdate] = useState(t0);
62 + let t1;
63 + let t2;
64 + if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
65 + t1 = () => {
66 + const unsubscribe = externalStore.subscribe(() => {
67 + forceUpdate({});
68 + });
69 + return unsubscribe;
70 + };
71 + t2 = [];
72 + $[1] = t1;
73 + $[2] = t2;
74 + } else {
75 + t1 = $[1];
76 + t2 = $[2];
77 + }
78 + useEffect(t1, t2);
79 + let t3;
80 + if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
81 + t3 = <div>{externalStore.getValue()}</div>;
82 + $[3] = t3;
83 + } else {
84 + t3 = $[3];
85 + }
86 + return t3;
87 +}
88 +
89 +export const FIXTURE_ENTRYPOINT = {
90 + fn: ExternalDataComponent,
91 + params: [],
92 +};
93 +
94 +```
95 +
96 +### Eval output
97 +(kind: ok) <div>0</div>
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-force-update.js new
+28
@@ -0,0 +1,28 @@
1 +// @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect
2 +import {useState, useEffect} from 'react';
3 +
4 +const externalStore = {
5 + value: 0,
6 + subscribe(callback) {
7 + return () => {};
8 + },
9 + getValue() {
10 + return this.value;
11 + },
12 +};
13 +
14 +function ExternalDataComponent() {
15 + const [, forceUpdate] = useState({});
16 + useEffect(() => {
17 + const unsubscribe = externalStore.subscribe(() => {
18 + forceUpdate({});
19 + });
20 + return unsubscribe;
21 + }, []);
22 + return <div>{externalStore.getValue()}</div>;
23 +}
24 +
25 +export const FIXTURE_ENTRYPOINT = {
26 + fn: ExternalDataComponent,
27 + params: [],
28 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-non-local-derived.expect.md new
+68
@@ -0,0 +1,68 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect
6 +import {useState, useEffect} from 'react';
7 +
8 +function Child({firstName, lastName}) {
9 + const [fullName, setFullName] = useState('');
10 + useEffect(() => {
11 + setFullName(firstName + ' ' + lastName);
12 + }, [firstName, lastName]);
13 + return <div>{fullName}</div>;
14 +}
15 +
16 +export const FIXTURE_ENTRYPOINT = {
17 + fn: Child,
18 + params: [{firstName: 'John', lastName: 'Doe'}],
19 +};
20 +
21 +```
22 +
23 +## Code
24 +
25 +```javascript
26 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect
27 +import { useState, useEffect } from "react";
28 +
29 +function Child(t0) {
30 + const $ = _c(6);
31 + const { firstName, lastName } = t0;
32 + const [fullName, setFullName] = useState("");
33 + let t1;
34 + let t2;
35 + if ($[0] !== firstName || $[1] !== lastName) {
36 + t1 = () => {
37 + setFullName(firstName + " " + lastName);
38 + };
39 + t2 = [firstName, lastName];
40 + $[0] = firstName;
41 + $[1] = lastName;
42 + $[2] = t1;
43 + $[3] = t2;
44 + } else {
45 + t1 = $[2];
46 + t2 = $[3];
47 + }
48 + useEffect(t1, t2);
49 + let t3;
50 + if ($[4] !== fullName) {
51 + t3 = <div>{fullName}</div>;
52 + $[4] = fullName;
53 + $[5] = t3;
54 + } else {
55 + t3 = $[5];
56 + }
57 + return t3;
58 +}
59 +
60 +export const FIXTURE_ENTRYPOINT = {
61 + fn: Child,
62 + params: [{ firstName: "John", lastName: "Doe" }],
63 +};
64 +
65 +```
66 +
67 +### Eval output
68 +(kind: ok) <div>John Doe</div>
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-set-state-in-effect-verbose-non-local-derived.js new
+15
@@ -0,0 +1,15 @@
1 +// @validateNoSetStateInEffects @enableVerboseNoSetStateInEffect
2 +import {useState, useEffect} from 'react';
3 +
4 +function Child({firstName, lastName}) {
5 + const [fullName, setFullName] = useState('');
6 + useEffect(() => {
7 + setFullName(firstName + ' ' + lastName);
8 + }, [firstName, lastName]);
9 + return <div>{fullName}</div>;
10 +}
11 +
12 +export const FIXTURE_ENTRYPOINT = {
13 + fn: Child,
14 + params: [{firstName: 'John', lastName: 'Doe'}],
15 +};