@samitouri / QOS-React / commits / 835b00908b

[compiler] Allow setStates in use{Layout,Insertion}Effect where the set value is derived from a ref (#34462)

@stipsan found this issue where the compiler would bailout on the `useLayoutEffect` examples in the React docs. While setState in an effect is typically an anti-pattern due to the fact that it hurts performance through cascading renders, the one scenario where it _is_ allowed is if the value being set flows from a ref.

lauren committed Sep 10, 2025 at 14:56 UTC 835b00908b1b1c522323162b735a85c5f620ac81
13 files changed +504 -2
compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts
+1 -1
@@ -276,7 +276,7 @@ function runWithEnvironment(
276 }
277
278 if (env.config.validateNoSetStateInEffects) {
279 - env.logErrors(validateNoSetStateInEffects(hir));
279 + env.logErrors(validateNoSetStateInEffects(hir, env));
280 }
281
282 if (env.config.validateNoJSXInTryStatements) {
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+7
@@ -660,6 +660,13 @@ export const EnvironmentConfigSchema = z.object({
660 * while its parent function remains uncompiled.
661 */
662 validateNoDynamicallyCreatedComponentsOrHooks: z.boolean().default(false),
663 +
664 + /**
665 + * When enabled, allows setState calls in effects when the value being set is
666 + * derived from a ref. This is useful for patterns where initial layout measurements
667 + * from refs need to be stored in state during mount.
668 + */
669 + enableAllowSetStateFromRefsInEffects: z.boolean().default(true),
670 });
671
672 export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoSetStateInEffects.ts
+58 -1
@@ -11,16 +11,23 @@ import {
11 ErrorCategory,
12 } from '../CompilerError';
13 import {
14 + Environment,
15 HIRFunction,
16 IdentifierId,
17 isSetStateType,
18 isUseEffectHookType,
19 isUseInsertionEffectHookType,
20 isUseLayoutEffectHookType,
21 + isUseRefType,
22 + isRefValueType,
23 Place,
24 } from '../HIR';
22 -import {eachInstructionValueOperand} from '../HIR/visitors';
25 +import {
26 + eachInstructionLValue,
27 + eachInstructionValueOperand,
28 +} from '../HIR/visitors';
29 import {Result} from '../Utils/Result';
30 +import {Iterable_some} from '../Utils/utils';
31
32 /**
33 * Validates against calling setState in the body of an effect (useEffect and friends),
@@ -32,6 +39,7 @@ import {Result} from '../Utils/Result';
39 */
40 export function validateNoSetStateInEffects(
41 fn: HIRFunction,
42 + env: Environment,
43 ): Result<void, CompilerError> {
44 const setStateFunctions: Map<IdentifierId, Place> = new Map();
45 const errors = new CompilerError();
@@ -72,6 +80,7 @@ export function validateNoSetStateInEffects(
80 const callee = getSetStateCall(
81 instr.value.loweredFunc.func,
82 setStateFunctions,
83 + env,
84 );
85 if (callee !== null) {
86 setStateFunctions.set(instr.lvalue.identifier.id, callee);
@@ -129,9 +138,42 @@ export function validateNoSetStateInEffects(
138 function getSetStateCall(
139 fn: HIRFunction,
140 setStateFunctions: Map<IdentifierId, Place>,
141 + env: Environment,
142 ): Place | null {
143 + const refDerivedValues: Set<IdentifierId> = new Set();
144 +
145 + const isDerivedFromRef = (place: Place): boolean => {
146 + return (
147 + refDerivedValues.has(place.identifier.id) ||
148 + isUseRefType(place.identifier) ||
149 + isRefValueType(place.identifier)
150 + );
151 + };
152 +
153 for (const [, block] of fn.body.blocks) {
154 for (const instr of block.instructions) {
155 + if (env.config.enableAllowSetStateFromRefsInEffects) {
156 + const hasRefOperand = Iterable_some(
157 + eachInstructionValueOperand(instr.value),
158 + isDerivedFromRef,
159 + );
160 +
161 + if (hasRefOperand) {
162 + for (const lvalue of eachInstructionLValue(instr)) {
163 + refDerivedValues.add(lvalue.identifier.id);
164 + }
165 + }
166 +
167 + if (
168 + instr.value.kind === 'PropertyLoad' &&
169 + instr.value.property === 'current' &&
170 + (isUseRefType(instr.value.object.identifier) ||
171 + isRefValueType(instr.value.object.identifier))
172 + ) {
173 + refDerivedValues.add(instr.lvalue.identifier.id);
174 + }
175 + }
176 +
177 switch (instr.value.kind) {
178 case 'LoadLocal': {
179 if (setStateFunctions.has(instr.value.place.identifier.id)) {
@@ -161,6 +203,21 @@ function getSetStateCall(
203 isSetStateType(callee.identifier) ||
204 setStateFunctions.has(callee.identifier.id)
205 ) {
206 + if (env.config.enableAllowSetStateFromRefsInEffects) {
207 + const arg = instr.value.args.at(0);
208 + if (
209 + arg !== undefined &&
210 + arg.kind === 'Identifier' &&
211 + refDerivedValues.has(arg.identifier.id)
212 + ) {
213 + /**
214 + * The one special case where we allow setStates in effects is in the very specific
215 + * scenario where the value being set is derived from a ref. For example this may
216 + * be needed when initial layout measurements from refs need to be stored in state.
217 + */
218 + return null;
219 + }
220 + }
221 /*
222 * TODO: once we support multiple locations per error, we should link to the
223 * original Place in the case that setStateFunction.has(callee)
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-set-state-in-useEffect-from-ref.expect.md new
+63
@@ -0,0 +1,63 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects
6 +import {useState, useRef, useEffect} from 'react';
7 +
8 +function Tooltip() {
9 + const ref = useRef(null);
10 + const [tooltipHeight, setTooltipHeight] = useState(0);
11 +
12 + useEffect(() => {
13 + const {height} = ref.current.getBoundingClientRect();
14 + setTooltipHeight(height);
15 + }, []);
16 +
17 + return tooltipHeight;
18 +}
19 +
20 +export const FIXTURE_ENTRYPOINT = {
21 + fn: Tooltip,
22 + params: [],
23 +};
24 +
25 +```
26 +
27 +## Code
28 +
29 +```javascript
30 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects
31 +import { useState, useRef, useEffect } from "react";
32 +
33 +function Tooltip() {
34 + const $ = _c(2);
35 + const ref = useRef(null);
36 + const [tooltipHeight, setTooltipHeight] = useState(0);
37 + let t0;
38 + let t1;
39 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
40 + t0 = () => {
41 + const { height } = ref.current.getBoundingClientRect();
42 + setTooltipHeight(height);
43 + };
44 + t1 = [];
45 + $[0] = t0;
46 + $[1] = t1;
47 + } else {
48 + t0 = $[0];
49 + t1 = $[1];
50 + }
51 + useEffect(t0, t1);
52 + return tooltipHeight;
53 +}
54 +
55 +export const FIXTURE_ENTRYPOINT = {
56 + fn: Tooltip,
57 + params: [],
58 +};
59 +
60 +```
61 +
62 +### Eval output
63 +(kind: exception) Cannot read properties of null (reading 'getBoundingClientRect')
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-set-state-in-useEffect-from-ref.js new
+19
@@ -0,0 +1,19 @@
1 +// @validateNoSetStateInEffects
2 +import {useState, useRef, useEffect} from 'react';
3 +
4 +function Tooltip() {
5 + const ref = useRef(null);
6 + const [tooltipHeight, setTooltipHeight] = useState(0);
7 +
8 + useEffect(() => {
9 + const {height} = ref.current.getBoundingClientRect();
10 + setTooltipHeight(height);
11 + }, []);
12 +
13 + return tooltipHeight;
14 +}
15 +
16 +export const FIXTURE_ENTRYPOINT = {
17 + fn: Tooltip,
18 + params: [],
19 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-effect-from-ref-arithmetic.expect.md new
+68
@@ -0,0 +1,68 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
6 +import {useState, useRef, useLayoutEffect} from 'react';
7 +
8 +function Component() {
9 + const ref = useRef({size: 5});
10 + const [computedSize, setComputedSize] = useState(0);
11 +
12 + useLayoutEffect(() => {
13 + setComputedSize(ref.current.size * 10);
14 + }, []);
15 +
16 + return computedSize;
17 +}
18 +
19 +export const FIXTURE_ENTRYPOINT = {
20 + fn: Component,
21 + params: [],
22 +};
23 +
24 +```
25 +
26 +## Code
27 +
28 +```javascript
29 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
30 +import { useState, useRef, useLayoutEffect } from "react";
31 +
32 +function Component() {
33 + const $ = _c(3);
34 + let t0;
35 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
36 + t0 = { size: 5 };
37 + $[0] = t0;
38 + } else {
39 + t0 = $[0];
40 + }
41 + const ref = useRef(t0);
42 + const [computedSize, setComputedSize] = useState(0);
43 + let t1;
44 + let t2;
45 + if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
46 + t1 = () => {
47 + setComputedSize(ref.current.size * 10);
48 + };
49 + t2 = [];
50 + $[1] = t1;
51 + $[2] = t2;
52 + } else {
53 + t1 = $[1];
54 + t2 = $[2];
55 + }
56 + useLayoutEffect(t1, t2);
57 + return computedSize;
58 +}
59 +
60 +export const FIXTURE_ENTRYPOINT = {
61 + fn: Component,
62 + params: [],
63 +};
64 +
65 +```
66 +
67 +### Eval output
68 +(kind: ok) 50
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-effect-from-ref-arithmetic.js new
+18
@@ -0,0 +1,18 @@
1 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
2 +import {useState, useRef, useLayoutEffect} from 'react';
3 +
4 +function Component() {
5 + const ref = useRef({size: 5});
6 + const [computedSize, setComputedSize] = useState(0);
7 +
8 + useLayoutEffect(() => {
9 + setComputedSize(ref.current.size * 10);
10 + }, []);
11 +
12 + return computedSize;
13 +}
14 +
15 +export const FIXTURE_ENTRYPOINT = {
16 + fn: Component,
17 + params: [],
18 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-effect-from-ref-array-index.expect.md new
+69
@@ -0,0 +1,69 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
6 +import {useState, useRef, useEffect} from 'react';
7 +
8 +function Component() {
9 + const ref = useRef([1, 2, 3, 4, 5]);
10 + const [value, setValue] = useState(0);
11 +
12 + useEffect(() => {
13 + const index = 2;
14 + setValue(ref.current[index]);
15 + }, []);
16 +
17 + return value;
18 +}
19 +
20 +export const FIXTURE_ENTRYPOINT = {
21 + fn: Component,
22 + params: [],
23 +};
24 +
25 +```
26 +
27 +## Code
28 +
29 +```javascript
30 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
31 +import { useState, useRef, useEffect } from "react";
32 +
33 +function Component() {
34 + const $ = _c(3);
35 + let t0;
36 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
37 + t0 = [1, 2, 3, 4, 5];
38 + $[0] = t0;
39 + } else {
40 + t0 = $[0];
41 + }
42 + const ref = useRef(t0);
43 + const [value, setValue] = useState(0);
44 + let t1;
45 + let t2;
46 + if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
47 + t1 = () => {
48 + setValue(ref.current[2]);
49 + };
50 + t2 = [];
51 + $[1] = t1;
52 + $[2] = t2;
53 + } else {
54 + t1 = $[1];
55 + t2 = $[2];
56 + }
57 + useEffect(t1, t2);
58 + return value;
59 +}
60 +
61 +export const FIXTURE_ENTRYPOINT = {
62 + fn: Component,
63 + params: [],
64 +};
65 +
66 +```
67 +
68 +### Eval output
69 +(kind: ok) 3
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-effect-from-ref-array-index.js new
+19
@@ -0,0 +1,19 @@
1 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
2 +import {useState, useRef, useEffect} from 'react';
3 +
4 +function Component() {
5 + const ref = useRef([1, 2, 3, 4, 5]);
6 + const [value, setValue] = useState(0);
7 +
8 + useEffect(() => {
9 + const index = 2;
10 + setValue(ref.current[index]);
11 + }, []);
12 +
13 + return value;
14 +}
15 +
16 +export const FIXTURE_ENTRYPOINT = {
17 + fn: Component,
18 + params: [],
19 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-effect-from-ref-function-call.expect.md new
+75
@@ -0,0 +1,75 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
6 +import {useState, useRef, useEffect} from 'react';
7 +
8 +function Component() {
9 + const ref = useRef(null);
10 + const [width, setWidth] = useState(0);
11 +
12 + useEffect(() => {
13 + function getBoundingRect(ref) {
14 + if (ref.current) {
15 + return ref.current.getBoundingClientRect?.()?.width ?? 100;
16 + }
17 + return 100;
18 + }
19 +
20 + setWidth(getBoundingRect(ref));
21 + }, []);
22 +
23 + return width;
24 +}
25 +
26 +export const FIXTURE_ENTRYPOINT = {
27 + fn: Component,
28 + params: [],
29 +};
30 +
31 +```
32 +
33 +## Code
34 +
35 +```javascript
36 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
37 +import { useState, useRef, useEffect } from "react";
38 +
39 +function Component() {
40 + const $ = _c(2);
41 + const ref = useRef(null);
42 + const [width, setWidth] = useState(0);
43 + let t0;
44 + let t1;
45 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
46 + t0 = () => {
47 + const getBoundingRect = function getBoundingRect(ref_0) {
48 + if (ref_0.current) {
49 + return ref_0.current.getBoundingClientRect?.()?.width ?? 100;
50 + }
51 + return 100;
52 + };
53 +
54 + setWidth(getBoundingRect(ref));
55 + };
56 + t1 = [];
57 + $[0] = t0;
58 + $[1] = t1;
59 + } else {
60 + t0 = $[0];
61 + t1 = $[1];
62 + }
63 + useEffect(t0, t1);
64 + return width;
65 +}
66 +
67 +export const FIXTURE_ENTRYPOINT = {
68 + fn: Component,
69 + params: [],
70 +};
71 +
72 +```
73 +
74 +### Eval output
75 +(kind: ok) 100
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-effect-from-ref-function-call.js new
+25
@@ -0,0 +1,25 @@
1 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
2 +import {useState, useRef, useEffect} from 'react';
3 +
4 +function Component() {
5 + const ref = useRef(null);
6 + const [width, setWidth] = useState(0);
7 +
8 + useEffect(() => {
9 + function getBoundingRect(ref) {
10 + if (ref.current) {
11 + return ref.current.getBoundingClientRect?.()?.width ?? 100;
12 + }
13 + return 100;
14 + }
15 +
16 + setWidth(getBoundingRect(ref));
17 + }, []);
18 +
19 + return width;
20 +}
21 +
22 +export const FIXTURE_ENTRYPOINT = {
23 + fn: Component,
24 + params: [],
25 +};
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useLayoutEffect-from-ref.expect.md new
+63
@@ -0,0 +1,63 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
6 +import {useState, useRef, useLayoutEffect} from 'react';
7 +
8 +function Tooltip() {
9 + const ref = useRef(null);
10 + const [tooltipHeight, setTooltipHeight] = useState(0);
11 +
12 + useLayoutEffect(() => {
13 + const {height} = ref.current.getBoundingClientRect();
14 + setTooltipHeight(height);
15 + }, []);
16 +
17 + return tooltipHeight;
18 +}
19 +
20 +export const FIXTURE_ENTRYPOINT = {
21 + fn: Tooltip,
22 + params: [],
23 +};
24 +
25 +```
26 +
27 +## Code
28 +
29 +```javascript
30 +import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
31 +import { useState, useRef, useLayoutEffect } from "react";
32 +
33 +function Tooltip() {
34 + const $ = _c(2);
35 + const ref = useRef(null);
36 + const [tooltipHeight, setTooltipHeight] = useState(0);
37 + let t0;
38 + let t1;
39 + if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
40 + t0 = () => {
41 + const { height } = ref.current.getBoundingClientRect();
42 + setTooltipHeight(height);
43 + };
44 + t1 = [];
45 + $[0] = t0;
46 + $[1] = t1;
47 + } else {
48 + t0 = $[0];
49 + t1 = $[1];
50 + }
51 + useLayoutEffect(t0, t1);
52 + return tooltipHeight;
53 +}
54 +
55 +export const FIXTURE_ENTRYPOINT = {
56 + fn: Tooltip,
57 + params: [],
58 +};
59 +
60 +```
61 +
62 +### Eval output
63 +(kind: exception) Cannot read properties of null (reading 'getBoundingClientRect')
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/valid-setState-in-useLayoutEffect-from-ref.js new
+19
@@ -0,0 +1,19 @@
1 +// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects
2 +import {useState, useRef, useLayoutEffect} from 'react';
3 +
4 +function Tooltip() {
5 + const ref = useRef(null);
6 + const [tooltipHeight, setTooltipHeight] = useState(0);
7 +
8 + useLayoutEffect(() => {
9 + const {height} = ref.current.getBoundingClientRect();
10 + setTooltipHeight(height);
11 + }, []);
12 +
13 + return tooltipHeight;
14 +}
15 +
16 +export const FIXTURE_ENTRYPOINT = {
17 + fn: Tooltip,
18 + params: [],
19 +};