Skip to content

Commit 646c429

Browse files
committed
Separate rules
#15
1 parent ad70014 commit 646c429

24 files changed

+799
-617
lines changed

README.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,23 @@ export default [
4848

4949
The plugin will have more information to act upon when you pass the correct dependencies to your effect — [`react-hooks/exhaustive-deps`](https://www.npmjs.com/package/eslint-plugin-react-hooks).
5050

51-
## 🔎 Rule: `you-might-not-need-an-effect`
51+
## 🔎 Rules
5252

53-
Determines when an effect is likely unnecessary, such as when it:
53+
**Legend:**
54+
- 🟡 = Enabled as a warning in the recommended config
55+
- 🔴 = Enabled as an error in the recommended config
56+
- ⚪ = Not enabled by default
5457

55-
- Stores derived state
56-
- Chains state updates
57-
- Initializes state
58-
- Resets all state when props change
59-
- Couples parent and child state or behavior
58+
| Rule | Description | Documentation | Default |
59+
|------|-------------|---------------|---------|
60+
| `no-derived-state` | Disallow storing derived state in an effect. | [docs](https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state) | 🟡 |
61+
| `no-chain-state-updates` | Disallow chaining state changes in an effect. | [docs](https://react.dev/learn/you-might-not-need-an-effect#chains-of-computations) | 🟡 |
62+
| `no-initialize-state` | Disallow initializing state in an effect. || 🟡 |
63+
| `no-reset-all-state-when-a-prop-changes` | Disallow resetting all state in an effect when a prop changes. | [docs](https://react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes) | 🟡 |
64+
| `no-event-handler` | Disallow using state and an effect as an event handler. | [docs](https://react.dev/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers) | 🟡 |
65+
| `no-parent-child-coupling` | Disallow coupling parent behavior or state to a child component in an effect. | [docs](https://react.dev/learn/you-might-not-need-an-effect#notifying-parent-components-about-state-changes) | 🟡 |
66+
| `no-empty-effect` | Disallow empty effects. || 🟡 |
6067

61-
When possible, also suggests the more idiomatic pattern.
62-
63-
While the effect may be unnecessary, we cannot reliably determine that when it:
64-
65-
- Uses external state
66-
- Calls external functions
67-
- Uses internal state to handle events
6868

6969
## ⚠️ Limitations
7070

@@ -77,3 +77,4 @@ This plugin aims to minimize false positives and accepts that some false negativ
7777
- https://react.dev/learn/synchronizing-with-effects
7878
- https://react.dev/learn/separating-events-from-effects
7979
- https://react.dev/learn/lifecycle-of-reactive-effects
80+

src/index.js

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
1-
import { name as ruleName, rule } from "./rule.js";
1+
import * as noEmptyEffect from "./no-empty-effect.js";
2+
import * as noResetAllStateWhenAPropChanges from "./no-reset-all-state-when-a-prop-changes.js";
3+
import * as noEventHandler from "./no-event-handler.js";
4+
import * as noParentChildCoupling from "./no-parent-child-coupling.js";
5+
import * as noInitializeState from "./no-initialize-state.js";
6+
import * as noChainStateUpdates from "./no-chain-state-updates.js";
7+
import * as noDerivedState from "./no-derived-state.js";
28
import globals from "globals";
39

4-
const pluginName = "react-you-might-not-need-an-effect";
5-
610
const plugin = {
711
meta: {
8-
name: pluginName,
12+
name: "react-you-might-not-need-an-effect",
913
},
1014
configs: {},
1115
rules: {
12-
[ruleName]: rule,
16+
[noEmptyEffect.name]: noEmptyEffect.rule,
17+
[noResetAllStateWhenAPropChanges.name]:
18+
noResetAllStateWhenAPropChanges.rule,
19+
[noEventHandler.name]: noEventHandler.rule,
20+
[noParentChildCoupling.name]: noParentChildCoupling.rule,
21+
[noInitializeState.name]: noInitializeState.rule,
22+
[noChainStateUpdates.name]: noChainStateUpdates.rule,
23+
[noDerivedState.name]: noDerivedState.rule,
24+
},
25+
};
26+
27+
const recommendedRules = Object.keys(plugin.rules).reduce((acc, ruleName) => {
28+
acc[plugin.meta.name + "/" + ruleName] = "warn";
29+
return acc;
30+
}, {});
31+
const languageOptions = {
32+
globals: {
33+
// NOTE: Required so we can resolve global references to their upstream global variables
34+
...globals.browser,
35+
},
36+
parserOptions: {
37+
ecmaFeatures: {
38+
jsx: true,
39+
},
1340
},
1441
};
1542

@@ -19,38 +46,15 @@ Object.assign(plugin.configs, {
1946
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"],
2047
plugins: {
2148
// Object.assign above so we can reference `plugin` here
22-
[pluginName]: plugin,
23-
},
24-
rules: {
25-
[pluginName + "/" + ruleName]: "warn",
26-
},
27-
languageOptions: {
28-
globals: {
29-
// NOTE: Required so we can resolve global references to their upstream global variables
30-
...globals.browser,
31-
},
32-
parserOptions: {
33-
ecmaFeatures: {
34-
jsx: true,
35-
},
36-
},
49+
[plugin.meta.name]: plugin,
3750
},
51+
rules: recommendedRules,
52+
languageOptions,
3853
},
39-
// eslintrc format
4054
"legacy-recommended": {
41-
plugins: [pluginName],
42-
rules: {
43-
[pluginName + "/" + ruleName]: "warn",
44-
},
45-
globals: {
46-
// NOTE: Required so we can resolve global references to their upstream global variables
47-
...globals.browser,
48-
},
49-
parserOptions: {
50-
ecmaFeatures: {
51-
jsx: true,
52-
},
53-
},
55+
plugins: [plugin.meta.name],
56+
rules: recommendedRules,
57+
...languageOptions,
5458
},
5559
});
5660

src/messages.js

Lines changed: 0 additions & 27 deletions
This file was deleted.

src/no-chain-state-updates.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { getCallExpr, getDownstreamRefs } from "./util/ast.js";
2+
import {
3+
getDependenciesRefs,
4+
getEffectFnRefs,
5+
getUpstreamReactVariables,
6+
isDirectCall,
7+
isFnRef,
8+
isHOCProp,
9+
isProp,
10+
isState,
11+
isStateSetter,
12+
isUseEffect,
13+
} from "./util/react.js";
14+
15+
export const name = "no-chain-state-updates";
16+
export const messages = {
17+
avoidChainingStateUpdates: "avoidChainingStateUpdates",
18+
};
19+
export const rule = {
20+
meta: {
21+
type: "suggestion",
22+
docs: {
23+
description: "Disallow chaining state changes in an effect.",
24+
url: "https://react.dev/learn/you-might-not-need-an-effect#chains-of-computations",
25+
},
26+
schema: [],
27+
messages: {
28+
[messages.avoidChainingStateUpdates]:
29+
"Avoid chaining state changes. When possible, update all relevant state simultaneously.",
30+
},
31+
},
32+
create: (context) => ({
33+
CallExpression: (node) => {
34+
if (!isUseEffect(node)) return;
35+
const effectFnRefs = getEffectFnRefs(context, node);
36+
const depsRefs = getDependenciesRefs(context, node);
37+
if (!effectFnRefs || !depsRefs) return;
38+
39+
const isAllDepsInternal = depsRefs
40+
.flatMap((ref) => getUpstreamReactVariables(context, ref.identifier))
41+
.notEmptyEvery(
42+
(variable) =>
43+
isState(variable) || (isProp(variable) && !isHOCProp(variable)),
44+
);
45+
46+
effectFnRefs
47+
.filter(isFnRef)
48+
.filter((ref) => isDirectCall(ref.identifier))
49+
.filter((ref) => isStateSetter(context, ref))
50+
.forEach((ref) => {
51+
const callExpr = getCallExpr(ref);
52+
const argsRefs = callExpr.arguments.flatMap((arg) =>
53+
getDownstreamRefs(context, arg),
54+
);
55+
const argsUpstreamVariables = argsRefs.flatMap((ref) =>
56+
getUpstreamReactVariables(context, ref.identifier),
57+
);
58+
const isAllArgsInternal = argsUpstreamVariables.notEmptyEvery(
59+
(variable) =>
60+
isState(variable) || (isProp(variable) && !isHOCProp(variable)),
61+
);
62+
const isSomeArgsExternal = argsUpstreamVariables.some(
63+
(variable) =>
64+
(!isState(variable) && !isProp(variable)) || isHOCProp(variable),
65+
);
66+
67+
if (!isAllArgsInternal && !isSomeArgsExternal && isAllDepsInternal) {
68+
context.report({
69+
node: callExpr,
70+
messageId: messages.avoidChainingStateUpdates,
71+
});
72+
}
73+
});
74+
},
75+
}),
76+
};

src/no-derived-state.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {
2+
isUseEffect,
3+
getEffectFnRefs,
4+
getDependenciesRefs,
5+
isFnRef,
6+
isDirectCall,
7+
isStateSetter,
8+
getUseStateNode,
9+
isProp,
10+
isHOCProp,
11+
getUpstreamReactVariables,
12+
isState,
13+
} from "./util/react.js";
14+
import { getCallExpr, getDownstreamRefs } from "./util/ast.js";
15+
import { arraysEqual } from "./util/javascript.js";
16+
17+
export const name = "no-derived-state";
18+
export const messages = {
19+
avoidDerivedState: "avoidDerivedState",
20+
};
21+
export const rule = {
22+
meta: {
23+
type: "suggestion",
24+
docs: {
25+
description: "Disallow storing derived state in an effect.",
26+
url: "https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state",
27+
},
28+
schema: [],
29+
messages: {
30+
[messages.avoidDerivedState]:
31+
'Avoid storing derived state. Compute "{{state}}" directly during render, optionally with `useMemo` if it\'s expensive.',
32+
},
33+
},
34+
create: (context) => ({
35+
CallExpression: (node) => {
36+
if (!isUseEffect(node)) return;
37+
const effectFnRefs = getEffectFnRefs(context, node);
38+
const depsRefs = getDependenciesRefs(context, node);
39+
if (!effectFnRefs || !depsRefs) return;
40+
41+
effectFnRefs
42+
.filter(isFnRef)
43+
.filter((ref) => isDirectCall(ref.identifier))
44+
.filter((ref) => isStateSetter(context, ref))
45+
.forEach((ref) => {
46+
const callExpr = getCallExpr(ref);
47+
const useStateNode = getUseStateNode(context, ref);
48+
const stateName = (
49+
useStateNode.id.elements[0] ?? useStateNode.id.elements[1]
50+
)?.name;
51+
const argsRefs = callExpr.arguments.flatMap((arg) =>
52+
getDownstreamRefs(context, arg),
53+
);
54+
const argsUpstreamVariables = argsRefs.flatMap((ref) =>
55+
getUpstreamReactVariables(context, ref.identifier),
56+
);
57+
const isAllArgsInternal = argsUpstreamVariables.notEmptyEvery(
58+
(variable) =>
59+
isState(variable) || (isProp(variable) && !isHOCProp(variable)),
60+
);
61+
const isAllArgsInDeps = argsRefs
62+
.filter((ref) =>
63+
ref.resolved.defs.every((def) => def.type !== "Parameter"),
64+
)
65+
.notEmptyEvery((argRef) =>
66+
depsRefs.some((depRef) =>
67+
arraysEqual(
68+
getUpstreamReactVariables(context, argRef.identifier),
69+
getUpstreamReactVariables(context, depRef.identifier),
70+
),
71+
),
72+
);
73+
const isStateSetterCalledOnce =
74+
ref.resolved.references.length - 1 === 1;
75+
76+
if (
77+
isAllArgsInternal ||
78+
(isAllArgsInDeps && isStateSetterCalledOnce)
79+
) {
80+
context.report({
81+
node: callExpr,
82+
messageId: messages.avoidDerivedState,
83+
data: { state: stateName },
84+
});
85+
}
86+
});
87+
},
88+
}),
89+
};

src/no-empty-effect.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { isUseEffect, getEffectFnRefs } from "./util/react.js";
2+
3+
export const name = "no-empty-effect";
4+
export const messages = {
5+
avoidEmptyEffect: "avoidEmptyEffect",
6+
};
7+
export const rule = {
8+
meta: {
9+
type: "suggestion",
10+
docs: {
11+
description: "Disallow empty React effects.",
12+
},
13+
schema: [],
14+
messages: {
15+
[messages.avoidEmptyEffect]: "This effect is empty and could be removed.",
16+
},
17+
},
18+
create: (context) => ({
19+
CallExpression: (node) => {
20+
if (!isUseEffect(node)) return;
21+
const effectFnRefs = getEffectFnRefs(context, node);
22+
23+
if (effectFnRefs?.length === 0) {
24+
// Hopefully it's obvious the effect can be removed.
25+
// More a follow-up for once they fix/remove other issues.
26+
context.report({
27+
node,
28+
messageId: messages.avoidEmptyEffect,
29+
});
30+
}
31+
},
32+
}),
33+
};

0 commit comments

Comments
 (0)