[eslint] Do not allow useEffectEvent fns to be called in arbitrary closures (#33544)
Summary: useEffectEvent is meant to be used specifically in combination with useEffect, and using the feature in arbitrary closures can lead to surprising reactivity semantics. In order to minimize risk in the experimental rollout, we are going to restrict its usage to being called directly inside an effect or another useEffectEvent, effectively enforcing the function coloring statically. Without an effect system this is the best we can do.
Jordan Brown committed
Jul 10, 2025 at 16:51 UTC
97cdd5d3c33eda77be4f96a43f72d6916d3badbb
2 files changed
+78
-70
packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js
+56
-56
@@ -1343,33 +1343,6 @@ if (__EXPERIMENTAL__) {
1343
}
1344
`,
1345
},
1346
- {
1347
- code: normalizeIndent`
1348
- // Valid because functions created with useEffectEvent can be called in closures.
1349
- function MyComponent({ theme }) {
1350
- const onClick = useEffectEvent(() => {
1351
- showNotification(theme);
1352
- });
1353
- return <Child onClick={() => onClick()}></Child>;
1354
- }
1355
- `,
1356
- },
1357
- {
1358
- code: normalizeIndent`
1359
- // Valid because functions created with useEffectEvent can be called in closures.
1360
- function MyComponent({ theme }) {
1361
- const onClick = useEffectEvent(() => {
1362
- showNotification(theme);
1363
- });
1364
- const onClick2 = () => { onClick() };
1365
- const onClick3 = useCallback(() => onClick(), []);
1366
- return <>
1367
- <Child onClick={onClick2}></Child>
1368
- <Child onClick={onClick3}></Child>
1369
- </>;
1370
- }
1371
- `,
1372
- },
1346
{
1347
code: normalizeIndent`
1348
// Valid because functions created with useEffectEvent can be passed by reference in useEffect
@@ -1380,47 +1353,39 @@ if (__EXPERIMENTAL__) {
1353
});
1354
const onClick2 = useEffectEvent(() => {
1355
debounce(onClick);
1356
+ debounce(() => onClick());
1357
+ debounce(() => { onClick() });
1358
+ deboucne(() => debounce(onClick));
1359
});
1360
useEffect(() => {
1385
- let id = setInterval(onClick, 100);
1361
+ let id = setInterval(() => onClick(), 100);
1362
return () => clearInterval(onClick);
1363
}, []);
1388
- return <Child onClick={() => onClick2()} />
1364
+ return null;
1365
}
1366
`,
1367
},
1392
- {
1393
- code: normalizeIndent`
1394
- const MyComponent = ({theme}) => {
1395
- const onClick = useEffectEvent(() => {
1396
- showNotification(theme);
1397
- });
1398
- return <Child onClick={() => onClick()}></Child>;
1399
- };
1400
- `,
1401
- },
1368
{
1369
code: normalizeIndent`
1370
function MyComponent({ theme }) {
1405
- const notificationService = useNotifications();
1406
- const showNotification = useEffectEvent((text) => {
1407
- notificationService.notify(theme, text);
1371
+ useEffect(() => {
1372
+ onClick();
1373
});
1409
- const onClick = useEffectEvent((text) => {
1410
- showNotification(text);
1374
+ const onClick = useEffectEvent(() => {
1375
+ showNotification(theme);
1376
});
1412
- return <Child onClick={(text) => onClick(text)} />
1377
}
1378
`,
1379
},
1380
{
1381
code: normalizeIndent`
1382
function MyComponent({ theme }) {
1419
- useEffect(() => {
1420
- onClick();
1383
+ const onEvent = useEffectEvent((text) => {
1384
+ console.log(text);
1385
});
1422
- const onClick = useEffectEvent(() => {
1423
- showNotification(theme);
1386
+
1387
+ useEffect(() => {
1388
+ onEvent('Hello world');
1389
});
1390
}
1391
`,
@@ -1437,7 +1402,7 @@ if (__EXPERIMENTAL__) {
1402
return <Child onClick={onClick}></Child>;
1403
}
1404
`,
1440
- errors: [useEffectEventError('onClick')],
1405
+ errors: [useEffectEventError('onClick', false)],
1406
},
1407
{
1408
code: normalizeIndent`
@@ -1456,8 +1421,23 @@ if (__EXPERIMENTAL__) {
1421
});
1422
return <Child onClick={() => onClick()} />
1423
}
1424
+
1425
+ // The useEffectEvent function shares an identifier name with the above
1426
+ function MyLastComponent({theme}) {
1427
+ const onClick = useEffectEvent(() => {
1428
+ showNotification(theme)
1429
+ });
1430
+ useEffect(() => {
1431
+ onClick(); // No error here, errors on all other uses
1432
+ onClick;
1433
+ })
1434
+ return <Child />
1435
+ }
1436
`,
1460
- errors: [{...useEffectEventError('onClick'), line: 7}],
1437
+ errors: [
1438
+ {...useEffectEventError('onClick', false), line: 7},
1439
+ {...useEffectEventError('onClick', true), line: 15},
1440
+ ],
1441
},
1442
{
1443
code: normalizeIndent`
@@ -1468,7 +1448,7 @@ if (__EXPERIMENTAL__) {
1448
return <Child onClick={onClick}></Child>;
1449
}
1450
`,
1471
- errors: [useEffectEventError('onClick')],
1451
+ errors: [useEffectEventError('onClick', false)],
1452
},
1453
{
1454
code: normalizeIndent`
@@ -1481,7 +1461,7 @@ if (__EXPERIMENTAL__) {
1461
return <Bar onClick={foo} />
1462
}
1463
`,
1484
- errors: [{...useEffectEventError('onClick'), line: 7}],
1464
+ errors: [{...useEffectEventError('onClick', false), line: 7}],
1465
},
1466
{
1467
code: normalizeIndent`
@@ -1497,7 +1477,27 @@ if (__EXPERIMENTAL__) {
1477
return <Child onClick={onClick} />
1478
}
1479
`,
1500
- errors: [useEffectEventError('onClick')],
1480
+ errors: [useEffectEventError('onClick', false)],
1481
+ },
1482
+ {
1483
+ code: normalizeIndent`
1484
+ // Invalid because functions created with useEffectEvent cannot be called in arbitrary closures.
1485
+ function MyComponent({ theme }) {
1486
+ const onClick = useEffectEvent(() => {
1487
+ showNotification(theme);
1488
+ });
1489
+ const onClick2 = () => { onClick() };
1490
+ const onClick3 = useCallback(() => onClick(), []);
1491
+ return <>
1492
+ <Child onClick={onClick2}></Child>
1493
+ <Child onClick={onClick3}></Child>
1494
+ </>;
1495
+ }
1496
+ `,
1497
+ errors: [
1498
+ useEffectEventError('onClick', true),
1499
+ useEffectEventError('onClick', true),
1500
+ ],
1501
},
1502
];
1503
}
@@ -1559,11 +1559,11 @@ function classError(hook) {
1559
};
1560
}
1561
1562
-function useEffectEventError(fn) {
1562
+function useEffectEventError(fn, called) {
1563
return {
1564
message:
1565
`\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
1566
- 'the same component. They cannot be assigned to variables or passed down.',
1566
+ `the same component.${called ? '' : ' They cannot be assigned to variables or passed down.'}`,
1567
};
1568
}
1569
packages/eslint-plugin-react-hooks/src/rules/RulesOfHooks.ts
+22
-14
@@ -541,7 +541,9 @@ const rule = {
541
context.report({
542
node: hook,
543
message:
544
- `React Hook "${getSourceCode().getText(hook)}" may be executed ` +
544
+ `React Hook "${getSourceCode().getText(
545
+ hook,
546
+ )}" may be executed ` +
547
'more than once. Possibly because it is called in a loop. ' +
548
'React Hooks must be called in the exact same order in ' +
549
'every component render.',
@@ -596,7 +598,9 @@ const rule = {
598
) {
599
// Custom message for hooks inside a class
600
const message =
599
- `React Hook "${getSourceCode().getText(hook)}" cannot be called ` +
601
+ `React Hook "${getSourceCode().getText(
602
+ hook,
603
+ )}" cannot be called ` +
604
'in a class component. React Hooks must be called in a ' +
605
'React function component or a custom React Hook function.';
606
context.report({node: hook, message});
@@ -613,7 +617,9 @@ const rule = {
617
} else if (codePathNode.type === 'Program') {
618
// These are dangerous if you have inline requires enabled.
619
const message =
616
- `React Hook "${getSourceCode().getText(hook)}" cannot be called ` +
620
+ `React Hook "${getSourceCode().getText(
621
+ hook,
622
+ )}" cannot be called ` +
623
'at the top level. React Hooks must be called in a ' +
624
'React function component or a custom React Hook function.';
625
context.report({node: hook, message});
@@ -626,7 +632,9 @@ const rule = {
632
// `use(...)` can be called in callbacks.
633
if (isSomewhereInsideComponentOrHook && !isUseIdentifier(hook)) {
634
const message =
629
- `React Hook "${getSourceCode().getText(hook)}" cannot be called ` +
635
+ `React Hook "${getSourceCode().getText(
636
+ hook,
637
+ )}" cannot be called ` +
638
'inside a callback. React Hooks must be called in a ' +
639
'React function component or a custom React Hook function.';
640
context.report({node: hook, message});
@@ -681,18 +689,18 @@ const rule = {
689
Identifier(node) {
690
// This identifier resolves to a useEffectEvent function, but isn't being referenced in an
691
// effect or another event function. It isn't being called either.
684
- if (
685
- lastEffect == null &&
686
- useEffectEventFunctions.has(node) &&
687
- node.parent.type !== 'CallExpression'
688
- ) {
692
+ if (lastEffect == null && useEffectEventFunctions.has(node)) {
693
+ const message =
694
+ `\`${getSourceCode().getText(
695
+ node,
696
+ )}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
697
+ 'the same component.' +
698
+ (node.parent.type === 'CallExpression'
699
+ ? ''
700
+ : ' They cannot be assigned to variables or passed down.');
701
context.report({
702
node,
691
- message:
692
- `\`${getSourceCode().getText(
693
- node,
694
- )}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
695
- 'the same component. They cannot be assigned to variables or passed down.',
703
+ message,
704
});
705
}
706
},