Skip to content

Commit 0b72027

Browse files
feat: Disallow a function wrapped in useCallback or a variable wrapped in useMemo yet only used in useEffect and only in one useEffect, closes #1278 (#1321)
Co-authored-by: REL1CX <[email protected]>
1 parent fb46fe7 commit 0b72027

File tree

6 files changed

+538
-91
lines changed

6 files changed

+538
-91
lines changed

packages/plugins/eslint-plugin-react-x/src/rules/no-unnecessary-use-callback.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ react-x/no-unnecessary-use-callback
2929
Disallow unnecessary usage of `useCallback`.
3030

3131
React Hooks `useCallback` has empty dependencies array like what's in the examples, are unnecessary. The hook can be removed and it's value can be created in the component body or hoisted to the outer scope of the component.
32+
If the calculated function is only used inside one useEffect the calculation can be moved inside the useEffect Function.
3233

3334
## Examples
3435

@@ -46,6 +47,22 @@ function MyComponent() {
4647
}
4748
```
4849

50+
51+
```tsx
52+
import { Button, MantineTheme } from "@mantine/core";
53+
import React, { useCallback, useEffect } from "react";
54+
55+
function MyComponent({items}: {items: string[]}) {
56+
const updateTest = useCallback(() => {console.log(items.length)}, [items]);
57+
58+
useEffect(() => {
59+
updateTest();
60+
}, [updateTest]);
61+
62+
return <div>Hello World</div>;
63+
}
64+
```
65+
4966
### Passing
5067

5168
```tsx
@@ -60,6 +77,19 @@ function MyComponent() {
6077
}
6178
```
6279

80+
```tsx
81+
import { Button, MantineTheme } from "@mantine/core";
82+
import React, { useEffect } from "react";
83+
84+
function MyComponent({items}: {items: string[]}) {
85+
useEffect(() => {
86+
console.log(items.length);
87+
}, [items]);
88+
89+
return <div>Hello World</div>;
90+
}
91+
```
92+
6393
## Implementation
6494

6595
- [Rule Source](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x/src/rules/no-unnecessary-use-callback.ts)

packages/plugins/eslint-plugin-react-x/src/rules/no-unnecessary-use-callback.spec.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,130 @@ ruleTester.run(RULE_NAME, rule, {
405405
},
406406
},
407407
},
408+
409+
{
410+
code: tsx`
411+
import {useCallback, useState, useEffect} from 'react';
412+
413+
function App({ items }) {
414+
const [test, setTest] = useState(0);
415+
416+
const updateTest = useCallback(() => {setTest(items.length)}, [items]);
417+
418+
useEffect(() => {
419+
updateTest();
420+
}, [updateTest]);
421+
422+
return <div>items</div>;
423+
}
424+
`,
425+
errors: [
426+
{
427+
messageId: "noUnnecessaryUseCallbackInsideUseEffect",
428+
},
429+
],
430+
settings: {
431+
"react-x": {
432+
importSource: "react",
433+
},
434+
},
435+
},
436+
{
437+
code: tsx`
438+
import {useCallback, useState, useEffect} from 'react';
439+
440+
function App({ items }) {
441+
const [test, setTest] = useState(0);
442+
443+
const updateTest = useCallback(() => {console.log('test')}, []);
444+
445+
useEffect(() => {
446+
updateTest();
447+
}, [updateTest]);
448+
449+
return <div>items</div>;
450+
}
451+
`,
452+
errors: [
453+
{
454+
messageId: "noUnnecessaryUseCallback",
455+
},
456+
],
457+
settings: {
458+
"react-x": {
459+
importSource: "react",
460+
},
461+
},
462+
},
463+
{
464+
code: tsx`
465+
import {useCallback, useState, useEffect} from 'react';
466+
467+
function App({ items }) {
468+
const [test, setTest] = useState(0);
469+
470+
const updateTest = useCallback(() => {setTest(items.length)}, [items]);
471+
472+
useEffect(() => {
473+
updateTest();
474+
}, [updateTest]);
475+
476+
return <div>items</div>;
477+
}
478+
479+
function App({ items }) {
480+
const [test, setTest] = useState(0);
481+
482+
const updateTest = useCallback(() => {setTest(items.length)}, [items]);
483+
484+
useEffect(() => {
485+
updateTest();
486+
}, [updateTest]);
487+
488+
return <div>items</div>;
489+
}
490+
`,
491+
errors: [
492+
{
493+
messageId: "noUnnecessaryUseCallbackInsideUseEffect",
494+
},
495+
{
496+
messageId: "noUnnecessaryUseCallbackInsideUseEffect",
497+
},
498+
],
499+
settings: {
500+
"react-x": {
501+
importSource: "react",
502+
},
503+
},
504+
},
505+
{
506+
code: tsx`
507+
const { useCallback, useEffect } = require("@pika/react");
508+
509+
function App({ items }) {
510+
const [test, setTest] = useState(0);
511+
512+
const updateTest = useCallback(() => {setTest(items.length)}, [items]);
513+
514+
useEffect(() => {
515+
updateTest();
516+
}, [updateTest]);
517+
518+
return <div>items</div>;
519+
}
520+
`,
521+
errors: [
522+
{
523+
messageId: "noUnnecessaryUseCallbackInsideUseEffect",
524+
},
525+
],
526+
settings: {
527+
"react-x": {
528+
importSource: "@pika/react",
529+
},
530+
},
531+
},
408532
],
409533
valid: [
410534
...allValid,
@@ -501,5 +625,70 @@ ruleTester.run(RULE_NAME, rule, {
501625
const refItem = useCallback(cb, deps)
502626
};
503627
`,
628+
629+
tsx`
630+
import { useCallback, useState, useEffect } from 'react';
631+
632+
function App({ items }) {
633+
const [test, setTest] = useState(items.length);
634+
635+
const updateTest = useCallback(() => { setTest(items.length + 1) }, [setTest, items]);
636+
637+
useEffect(function () {
638+
function foo() {
639+
updateTest();
640+
}
641+
642+
foo();
643+
644+
updateTest();
645+
}, [updateTest])
646+
647+
return <div onClick={() => updateTest()}>{test}</div>;
648+
}
649+
`,
650+
tsx`
651+
import { useCallback, useState, useEffect } from 'react';
652+
653+
const Component = () => {
654+
const [test, setTest] = useState(items.length);
655+
656+
const updateTest = useCallback(() => { setTest(items.length + 1) }, [setTest, items]);
657+
658+
useEffect(() => {
659+
// some condition
660+
updateTest();
661+
}, [updateTest]);
662+
663+
useEffect(() => {
664+
// some condition
665+
updateTest();
666+
}, [updateTest]);
667+
668+
return <div />;
669+
};
670+
`,
671+
tsx`
672+
import { useCallback, useState, useEffect } from 'react';
673+
674+
const Component = () => {
675+
const [test, setTest] = useState(items.length);
676+
677+
const updateTest = useCallback(() => { setTest(items.length + 1) }, [setTest, items]);
678+
679+
return <div ref={() => updateTest()} />;
680+
};
681+
`,
682+
tsx`
683+
import { useCallback, useState, useEffect } from 'react';
684+
685+
const Component = () => {
686+
const [test, setTest] = useState(items.length);
687+
688+
const updateTest = useCallback(() => { setTest(items.length + 1) }, [setTest, items]);
689+
690+
return <div onClick={updateTest} />;
691+
};
692+
`,
504693
],
505694
});

packages/plugins/eslint-plugin-react-x/src/rules/no-unnecessary-use-callback.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import * as AST from "@eslint-react/ast";
2-
import { isUseCallbackCall } from "@eslint-react/core";
2+
import { isUseCallbackCall, isUseEffectLikeCall } from "@eslint-react/core";
33
import { identity } from "@eslint-react/eff";
4-
import type { RuleContext, RuleFeature } from "@eslint-react/shared";
4+
import { type RuleContext, type RuleFeature, report } from "@eslint-react/shared";
55
import { findVariable, getChildScopes, getVariableDefinitionNode } from "@eslint-react/var";
6+
import type { TSESTree } from "@typescript-eslint/types";
67
import { AST_NODE_TYPES as T } from "@typescript-eslint/types";
7-
import type { RuleListener } from "@typescript-eslint/utils/ts-eslint";
8+
import { isIdentifier, isVariableDeclarator } from "@typescript-eslint/utils/ast-utils";
9+
import { type ReportDescriptor, type RuleListener, type SourceCode } from "@typescript-eslint/utils/ts-eslint";
810
import type { CamelCase } from "string-ts";
911
import { match } from "ts-pattern";
10-
1112
import { createRule } from "../utils";
1213

1314
export const RULE_NAME = "no-unnecessary-use-callback";
@@ -16,7 +17,7 @@ export const RULE_FEATURES = [
1617
"EXP",
1718
] as const satisfies RuleFeature[];
1819

19-
export type MessageID = CamelCase<typeof RULE_NAME>;
20+
export type MessageID = CamelCase<typeof RULE_NAME> | "noUnnecessaryUseCallbackInsideUseEffect";
2021

2122
export default createRule<[], MessageID>({
2223
meta: {
@@ -28,6 +29,8 @@ export default createRule<[], MessageID>({
2829
messages: {
2930
noUnnecessaryUseCallback:
3031
"An 'useCallback' with empty deps and no references to the component scope may be unnecessary.",
32+
noUnnecessaryUseCallbackInsideUseEffect:
33+
"{{name}} is only used inside 1 useEffect, which may be unnecessary. You can move the computation into useEffect directly and merge the dependency arrays.",
3134
},
3235
schema: [],
3336
},
@@ -39,13 +42,18 @@ export default createRule<[], MessageID>({
3942
export function create(context: RuleContext<MessageID, []>): RuleListener {
4043
// Fast path: skip if `useCallback` is not present in the file
4144
if (!context.sourceCode.text.includes("useCallback")) return {};
45+
4246
return {
4347
CallExpression(node) {
4448
if (!isUseCallbackCall(node)) {
4549
return;
4650
}
51+
52+
const checkForUsageInsideUseEffectReport = checkForUsageInsideUseEffect(context.sourceCode, node);
53+
4754
const initialScope = context.sourceCode.getScope(node);
4855
const component = context.sourceCode.getScope(node).block;
56+
4957
if (!AST.isFunction(component)) {
5058
return;
5159
}
@@ -67,8 +75,10 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
6775
.otherwise(() => false);
6876

6977
if (!hasEmptyDeps) {
78+
report(context)(checkForUsageInsideUseEffectReport);
7079
return;
7180
}
81+
7282
const arg0Node = match(arg0)
7383
.with({ type: T.ArrowFunctionExpression }, (n) => {
7484
if (n.body.type === T.ArrowFunctionExpression) {
@@ -97,7 +107,46 @@ export function create(context: RuleContext<MessageID, []>): RuleListener {
97107
messageId: "noUnnecessaryUseCallback",
98108
node,
99109
});
110+
return;
100111
}
112+
report(context)(checkForUsageInsideUseEffectReport);
101113
},
102114
};
103115
}
116+
117+
function checkForUsageInsideUseEffect(
118+
sourceCode: Readonly<SourceCode>,
119+
node: TSESTree.CallExpression,
120+
): ReportDescriptor<MessageID> | undefined {
121+
if (!/use\w*Effect/u.test(sourceCode.text)) return;
122+
123+
if (!isVariableDeclarator(node.parent)) {
124+
return;
125+
}
126+
127+
if (!isIdentifier(node.parent.id)) {
128+
return;
129+
}
130+
131+
const references = sourceCode.getDeclaredVariables(node.parent)[0]?.references ?? [];
132+
const usages = references.filter((ref) => !(ref.init ?? false));
133+
const effectSet = new Set<TSESTree.Node>();
134+
135+
for (const usage of usages) {
136+
const effect = AST.findParentNode(usage.identifier, isUseEffectLikeCall);
137+
138+
if (effect == null) {
139+
return;
140+
}
141+
142+
effectSet.add(effect);
143+
if (effectSet.size > 1) {
144+
return;
145+
}
146+
}
147+
return {
148+
messageId: "noUnnecessaryUseCallbackInsideUseEffect",
149+
node,
150+
data: { name: node.parent.id.name },
151+
};
152+
}

0 commit comments

Comments
 (0)