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