@samitouri / QOS-React / commits / 09f05694a2

[compiler] Extend setState in effect validation to useEffectEvent (#35214)

ValidateNoSetStateInEffects already supports transitive setter functions. This PR marks any synchonous state setter useEffectEvent function so we can validate that uEE isn't being used only as misdirection to avoid the validation within an effect body. The error points to the call of the effect event. Example: ```js export default function MyApp() { const [count, setCount] = useState(0) const effectEvent = useEffectEvent(() => { setCount(10) }) useEffect(() => { effectEvent() }, []) return <div>{count}</div>; ``` ``` Found 1 error: Error: Calling setState synchronously within an effect can trigger cascading renders Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following: * Update external systems with the latest state from React. * Subscribe for updates from some external system, calling setState in a callback function when external state changes. Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect). 5 | }) 6 | useEffect(() => { > 7 | effectEvent() | ^^^^^^^^^^^ Avoid calling setState() directly within an effect 8 | }, []) 9 | return <div>{count}</div>; 10 | } ```

Jack Pope committed Dec 1, 2025 at 14:55 UTC 09f05694a25578c46e846e4ff9495f7c1352c29c
8 files changed +500 -1
compiler/packages/babel-plugin-react-compiler/src/HIR/HIR.ts
+5
@@ -2023,6 +2023,11 @@ export function isUseInsertionEffectHookType(id: Identifier): boolean {
2023 id.type.shapeId === 'BuiltInUseInsertionEffectHook'
2024 );
2025 }
2026 +export function isUseEffectEventType(id: Identifier): boolean {
2027 + return (
2028 + id.type.kind === 'Function' && id.type.shapeId === 'BuiltInUseEffectEvent'
2029 + );
2030 +}
2031
2032 export function isUseContextHookType(id: Identifier): boolean {
2033 return (
compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts
+15 -1
@@ -16,6 +16,7 @@ import {
16 IdentifierId,
17 isSetStateType,
18 isUseEffectHookType,
19 + isUseEffectEventType,
20 isUseInsertionEffectHookType,
21 isUseLayoutEffectHookType,
22 isUseRefType,
@@ -98,7 +99,20 @@ export function validateNoSetStateInEffects(
99 instr.value.kind === 'MethodCall'
100 ? instr.value.receiver
101 : instr.value.callee;
101 - if (
102 +
103 + if (isUseEffectEventType(callee.identifier)) {
104 + const arg = instr.value.args[0];
105 + if (arg !== undefined && arg.kind === 'Identifier') {
106 + const setState = setStateFunctions.get(arg.identifier.id);
107 + if (setState !== undefined) {
108 + /**
109 + * This effect event function calls setState synchonously,
110 + * treat it as a setState function for transitive tracking
111 + */
112 + setStateFunctions.set(instr.lvalue.identifier.id, setState);
113 + }
114 + }
115 + } else if (
116 isUseEffectHookType(callee.identifier) ||
117 isUseLayoutEffectHookType(callee.identifier) ||
118 isUseInsertionEffectHookType(callee.identifier)
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.expect.md new
+71
@@ -0,0 +1,71 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @loggerTestOnly @validateNoSetStateInEffects
6 +import {useEffect, useEffectEvent, useState} from 'react';
7 +
8 +function Component() {
9 + const [state, setState] = useState(0);
10 + const effectEvent = useEffectEvent(() => {
11 + setState(true);
12 + });
13 + useEffect(() => {
14 + effectEvent();
15 + }, []);
16 + return state;
17 +}
18 +
19 +```
20 +
21 +## Code
22 +
23 +```javascript
24 +import { c as _c } from "react/compiler-runtime"; // @loggerTestOnly @validateNoSetStateInEffects
25 +import { useEffect, useEffectEvent, useState } from "react";
26 +
27 +function Component() {
28 + const $ = _c(4);
29 + const [state, setState] = useState(0);
30 + let t0;
31 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
32 + t0 = () => {
33 + setState(true);
34 + };
35 + $[0] = t0;
36 + } else {
37 + t0 = $[0];
38 + }
39 + const effectEvent = useEffectEvent(t0);
40 + let t1;
41 + if ($[1] !== effectEvent) {
42 + t1 = () => {
43 + effectEvent();
44 + };
45 + $[1] = effectEvent;
46 + $[2] = t1;
47 + } else {
48 + t1 = $[2];
49 + }
50 + let t2;
51 + if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
52 + t2 = [];
53 + $[3] = t2;
54 + } else {
55 + t2 = $[3];
56 + }
57 + useEffect(t1, t2);
58 + return state;
59 +}
60 +
61 +```
62 +
63 +## Logs
64 +
65 +```
66 +{"kind":"CompileError","detail":{"options":{"category":"EffectSetState","reason":"Calling setState synchronously within an effect can trigger cascading renders","description":"Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:\n* Update external systems with the latest state from React.\n* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\nCalling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect)","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":10,"column":4,"index":267},"end":{"line":10,"column":15,"index":278},"filename":"invalid-setState-in-useEffect-via-useEffectEvent.ts","identifierName":"effectEvent"},"message":"Avoid calling setState() directly within an effect"}]}},"fnLoc":null}
67 +{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":108},"end":{"line":13,"column":1,"index":309},"filename":"invalid-setState-in-useEffect-via-useEffectEvent.ts"},"fnName":"Component","memoSlots":4,"memoBlocks":3,"memoValues":3,"prunedMemoBlocks":0,"prunedMemoValues":0}
68 +```
69 +
70 +### Eval output
71 +(kind: exception) Fixture not implemented
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/invalid-setState-in-useEffect-via-useEffectEvent.js new
+13
@@ -0,0 +1,13 @@
1 +// @loggerTestOnly @validateNoSetStateInEffects
2 +import {useEffect, useEffectEvent, useState} from 'react';
3 +
4 +function Component() {
5 + const [state, setState] = useState(0);
6 + const effectEvent = useEffectEvent(() => {
7 + setState(true);
8 + });
9 + useEffect(() => {
10 + effectEvent();
11 + }, []);
12 + return state;
13 +}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.expect.md new
+116
@@ -0,0 +1,116 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects @loggerTestOnly @compilationMode:"infer"
6 +import {useEffect, useEffectEvent, useState} from 'react';
7 +
8 +const shouldSetState = false;
9 +
10 +function Component() {
11 + const [state, setState] = useState(0);
12 + const effectEvent = useEffectEvent(() => {
13 + setState(10);
14 + });
15 + useEffect(() => {
16 + setTimeout(effectEvent, 10);
17 + });
18 +
19 + const effectEventWithTimeout = useEffectEvent(() => {
20 + setTimeout(() => {
21 + setState(20);
22 + }, 10);
23 + });
24 + useEffect(() => {
25 + effectEventWithTimeout();
26 + }, []);
27 + return state;
28 +}
29 +
30 +export const FIXTURE_ENTRYPOINT = {
31 + fn: Component,
32 + params: [{}],
33 +};
34 +
35 +```
36 +
37 +## Code
38 +
39 +```javascript
40 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @loggerTestOnly @compilationMode:"infer"
41 +import { useEffect, useEffectEvent, useState } from "react";
42 +
43 +const shouldSetState = false;
44 +
45 +function Component() {
46 + const $ = _c(7);
47 + const [state, setState] = useState(0);
48 + let t0;
49 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
50 + t0 = () => {
51 + setState(10);
52 + };
53 + $[0] = t0;
54 + } else {
55 + t0 = $[0];
56 + }
57 + const effectEvent = useEffectEvent(t0);
58 + let t1;
59 + if ($[1] !== effectEvent) {
60 + t1 = () => {
61 + setTimeout(effectEvent, 10);
62 + };
63 + $[1] = effectEvent;
64 + $[2] = t1;
65 + } else {
66 + t1 = $[2];
67 + }
68 + useEffect(t1);
69 + let t2;
70 + if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
71 + t2 = () => {
72 + setTimeout(() => {
73 + setState(20);
74 + }, 10);
75 + };
76 + $[3] = t2;
77 + } else {
78 + t2 = $[3];
79 + }
80 + const effectEventWithTimeout = useEffectEvent(t2);
81 + let t3;
82 + if ($[4] !== effectEventWithTimeout) {
83 + t3 = () => {
84 + effectEventWithTimeout();
85 + };
86 + $[4] = effectEventWithTimeout;
87 + $[5] = t3;
88 + } else {
89 + t3 = $[5];
90 + }
91 + let t4;
92 + if ($[6] === Symbol.for("react.memo_cache_sentinel")) {
93 + t4 = [];
94 + $[6] = t4;
95 + } else {
96 + t4 = $[6];
97 + }
98 + useEffect(t3, t4);
99 + return state;
100 +}
101 +
102 +export const FIXTURE_ENTRYPOINT = {
103 + fn: Component,
104 + params: [{}],
105 +};
106 +
107 +```
108 +
109 +## Logs
110 +
111 +```
112 +{"kind":"CompileSuccess","fnLoc":{"start":{"line":6,"column":0,"index":164},"end":{"line":24,"column":1,"index":551},"filename":"valid-setState-in-useEffect-via-useEffectEvent-listener.ts"},"fnName":"Component","memoSlots":7,"memoBlocks":5,"memoValues":5,"prunedMemoBlocks":0,"prunedMemoValues":0}
113 +```
114 +
115 +### Eval output
116 +(kind: exception) (0 , _react.useEffectEvent) is not a function
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-listener.js new
+29
@@ -0,0 +1,29 @@
1 +// @validateNoSetStateInEffects @loggerTestOnly @compilationMode:"infer"
2 +import {useEffect, useEffectEvent, useState} from 'react';
3 +
4 +const shouldSetState = false;
5 +
6 +function Component() {
7 + const [state, setState] = useState(0);
8 + const effectEvent = useEffectEvent(() => {
9 + setState(10);
10 + });
11 + useEffect(() => {
12 + setTimeout(effectEvent, 10);
13 + });
14 +
15 + const effectEventWithTimeout = useEffectEvent(() => {
16 + setTimeout(() => {
17 + setState(20);
18 + }, 10);
19 + });
20 + useEffect(() => {
21 + effectEventWithTimeout();
22 + }, []);
23 + return state;
24 +}
25 +
26 +export const FIXTURE_ENTRYPOINT = {
27 + fn: Component,
28 + params: [{}],
29 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.expect.md new
+192
@@ -0,0 +1,192 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
6 +import {useState, useRef, useEffect, useEffectEvent} from 'react';
7 +
8 +function Component({x, y}) {
9 + const previousXRef = useRef(null);
10 + const previousYRef = useRef(null);
11 +
12 + const [data, setData] = useState(null);
13 +
14 + const effectEvent = useEffectEvent(() => {
15 + const data = load({x, y});
16 + setData(data);
17 + });
18 +
19 + useEffect(() => {
20 + const previousX = previousXRef.current;
21 + previousXRef.current = x;
22 + const previousY = previousYRef.current;
23 + previousYRef.current = y;
24 + if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
25 + effectEvent();
26 + }
27 + }, [x, y]);
28 +
29 + const effectEvent2 = useEffectEvent((xx, yy) => {
30 + const previousX = previousXRef.current;
31 + previousXRef.current = xx;
32 + const previousY = previousYRef.current;
33 + previousYRef.current = yy;
34 + if (!areEqual(xx, previousX) || !areEqual(yy, previousY)) {
35 + const data = load({x: xx, y: yy});
36 + setData(data);
37 + }
38 + });
39 +
40 + useEffect(() => {
41 + effectEvent2(x, y);
42 + }, [x, y]);
43 +
44 + return data;
45 +}
46 +
47 +function areEqual(a, b) {
48 + return a === b;
49 +}
50 +
51 +function load({x, y}) {
52 + return x * y;
53 +}
54 +
55 +export const FIXTURE_ENTRYPOINT = {
56 + fn: Component,
57 + params: [{x: 0, y: 0}],
58 + sequentialRenders: [
59 + {x: 0, y: 0},
60 + {x: 1, y: 0},
61 + {x: 1, y: 1},
62 + ],
63 +};
64 +
65 +```
66 +
67 +## Code
68 +
69 +```javascript
70 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
71 +import { useState, useRef, useEffect, useEffectEvent } from "react";
72 +
73 +function Component(t0) {
74 + const $ = _c(18);
75 + const { x, y } = t0;
76 + const previousXRef = useRef(null);
77 + const previousYRef = useRef(null);
78 +
79 + const [data, setData] = useState(null);
80 + let t1;
81 + if ($[0] !== x || $[1] !== y) {
82 + t1 = () => {
83 + const data_0 = load({ x, y });
84 + setData(data_0);
85 + };
86 + $[0] = x;
87 + $[1] = y;
88 + $[2] = t1;
89 + } else {
90 + t1 = $[2];
91 + }
92 + const effectEvent = useEffectEvent(t1);
93 + let t2;
94 + if ($[3] !== effectEvent || $[4] !== x || $[5] !== y) {
95 + t2 = () => {
96 + const previousX = previousXRef.current;
97 + previousXRef.current = x;
98 + const previousY = previousYRef.current;
99 + previousYRef.current = y;
100 + if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
101 + effectEvent();
102 + }
103 + };
104 + $[3] = effectEvent;
105 + $[4] = x;
106 + $[5] = y;
107 + $[6] = t2;
108 + } else {
109 + t2 = $[6];
110 + }
111 + let t3;
112 + if ($[7] !== x || $[8] !== y) {
113 + t3 = [x, y];
114 + $[7] = x;
115 + $[8] = y;
116 + $[9] = t3;
117 + } else {
118 + t3 = $[9];
119 + }
120 + useEffect(t2, t3);
121 + let t4;
122 + if ($[10] === Symbol.for("react.memo_cache_sentinel")) {
123 + t4 = (xx, yy) => {
124 + const previousX_0 = previousXRef.current;
125 + previousXRef.current = xx;
126 + const previousY_0 = previousYRef.current;
127 + previousYRef.current = yy;
128 + if (!areEqual(xx, previousX_0) || !areEqual(yy, previousY_0)) {
129 + const data_1 = load({ x: xx, y: yy });
130 + setData(data_1);
131 + }
132 + };
133 + $[10] = t4;
134 + } else {
135 + t4 = $[10];
136 + }
137 + const effectEvent2 = useEffectEvent(t4);
138 + let t5;
139 + if ($[11] !== effectEvent2 || $[12] !== x || $[13] !== y) {
140 + t5 = () => {
141 + effectEvent2(x, y);
142 + };
143 + $[11] = effectEvent2;
144 + $[12] = x;
145 + $[13] = y;
146 + $[14] = t5;
147 + } else {
148 + t5 = $[14];
149 + }
150 + let t6;
151 + if ($[15] !== x || $[16] !== y) {
152 + t6 = [x, y];
153 + $[15] = x;
154 + $[16] = y;
155 + $[17] = t6;
156 + } else {
157 + t6 = $[17];
158 + }
159 + useEffect(t5, t6);
160 + return data;
161 +}
162 +
163 +function areEqual(a, b) {
164 + return a === b;
165 +}
166 +
167 +function load({ x, y }) {
168 + return x * y;
169 +}
170 +
171 +export const FIXTURE_ENTRYPOINT = {
172 + fn: Component,
173 + params: [{ x: 0, y: 0 }],
174 + sequentialRenders: [
175 + { x: 0, y: 0 },
176 + { x: 1, y: 0 },
177 + { x: 1, y: 1 },
178 + ],
179 +};
180 +
181 +```
182 +
183 +## Logs
184 +
185 +```
186 +{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":179},"end":{"line":41,"column":1,"index":1116},"filename":"valid-setState-in-useEffect-via-useEffectEvent-with-ref.ts"},"fnName":"Component","memoSlots":18,"memoBlocks":6,"memoValues":6,"prunedMemoBlocks":0,"prunedMemoValues":0}
187 +```
188 +
189 +### Eval output
190 +(kind: ok) [[ (exception in render) TypeError: (0 , _react.useEffectEvent) is not a function ]]
191 +[[ (exception in render) TypeError: (0 , _react.useEffectEvent) is not a function ]]
192 +[[ (exception in render) TypeError: (0 , _react.useEffectEvent) is not a function ]]
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useEffect-via-useEffectEvent-with-ref.js new
+59
@@ -0,0 +1,59 @@
1 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
2 +import {useState, useRef, useEffect, useEffectEvent} from 'react';
3 +
4 +function Component({x, y}) {
5 + const previousXRef = useRef(null);
6 + const previousYRef = useRef(null);
7 +
8 + const [data, setData] = useState(null);
9 +
10 + const effectEvent = useEffectEvent(() => {
11 + const data = load({x, y});
12 + setData(data);
13 + });
14 +
15 + useEffect(() => {
16 + const previousX = previousXRef.current;
17 + previousXRef.current = x;
18 + const previousY = previousYRef.current;
19 + previousYRef.current = y;
20 + if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
21 + effectEvent();
22 + }
23 + }, [x, y]);
24 +
25 + const effectEvent2 = useEffectEvent((xx, yy) => {
26 + const previousX = previousXRef.current;
27 + previousXRef.current = xx;
28 + const previousY = previousYRef.current;
29 + previousYRef.current = yy;
30 + if (!areEqual(xx, previousX) || !areEqual(yy, previousY)) {
31 + const data = load({x: xx, y: yy});
32 + setData(data);
33 + }
34 + });
35 +
36 + useEffect(() => {
37 + effectEvent2(x, y);
38 + }, [x, y]);
39 +
40 + return data;
41 +}
42 +
43 +function areEqual(a, b) {
44 + return a === b;
45 +}
46 +
47 +function load({x, y}) {
48 + return x * y;
49 +}
50 +
51 +export const FIXTURE_ENTRYPOINT = {
52 + fn: Component,
53 + params: [{x: 0, y: 0}],
54 + sequentialRenders: [
55 + {x: 0, y: 0},
56 + {x: 1, y: 0},
57 + {x: 1, y: 1},
58 + ],
59 +};