[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
+};