Skip to content

Commit e3229c1

Browse files
authored
feat: add rule 'no-direct-set-state-in-use-effect', closes #628 (#629)
1 parent 204d945 commit e3229c1

File tree

10 files changed

+393
-16
lines changed

10 files changed

+393
-16
lines changed

eslint.config.mts

+2
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,14 @@ const config: FlatConfig[] = [
201201
"dedent",
202202
"html",
203203
"tsx",
204+
"ts",
204205
],
205206
tags: [
206207
"outdent",
207208
"dedent",
208209
"html",
209210
"tsx",
211+
"ts",
210212
],
211213
},
212214
],

packages/plugins/eslint-plugin-react-hooks-extra/README.md

+7-6
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ export default [
4040

4141
## Rules
4242

43-
| Rule | Description | 💼 | 💭 ||
44-
| :--------------------------------------- | :---------------------------------------------------------------- | :-: | :-: | :-: |
45-
| `ensure-custom-hooks-using-other-hooks` | Warns when custom Hooks that don't use other Hooks. | ✔️ | | |
46-
| `ensure-use-callback-has-non-empty-deps` | Warns when `useCallback` is called with empty dependencies array. | 🧐 | | |
47-
| `ensure-use-memo-has-non-empty-deps` | Warns when `useMemo` is called with empty dependencies array. | 🧐 | | |
48-
| `prefer-use-state-lazy-initialization` | Warns function calls made inside `useState` calls. | 🚀 | | |
43+
| Rule | Description | 💼 | 💭 ||
44+
| :--------------------------------------- | :------------------------------------------------------------------------ | :-: | :-: | :-: |
45+
| `ensure-custom-hooks-using-other-hooks` | Warns when custom Hooks that don't use other Hooks. | ✔️ | | |
46+
| `ensure-use-callback-has-non-empty-deps` | Warns when `useCallback` is called with empty dependencies array. | 🧐 | | |
47+
| `ensure-use-memo-has-non-empty-deps` | Warns when `useMemo` is called with empty dependencies array. | 🧐 | | |
48+
| `no-direct-set-state-in-use-effect` | Disallow direct calls to the `set` function of `useState` in `useEffect`. | ✔️ | | |
49+
| `prefer-use-state-lazy-initialization` | Warns function calls made inside `useState` calls. | 🚀 | | |

packages/plugins/eslint-plugin-react-hooks-extra/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { name, version } from "../package.json";
22
import ensureCustomHooksUsingOtherHooks from "./rules/ensure-custom-hooks-using-other-hooks";
33
import ensureUseCallbackHasNonEmptyDeps from "./rules/ensure-use-callback-has-non-empty-deps";
44
import ensureUseMemoHasNonEmptyDeps from "./rules/ensure-use-memo-has-non-empty-deps";
5+
import noDirectSetStateInUseEffect from "./rules/no-direct-set-state-in-use-effect";
56
import preferUseStateLazyInitialization from "./rules/prefer-use-state-lazy-initialization";
67

78
export const meta = {
@@ -13,5 +14,6 @@ export const rules = {
1314
"ensure-custom-hooks-using-other-hooks": ensureCustomHooksUsingOtherHooks,
1415
"ensure-use-callback-has-non-empty-deps": ensureUseCallbackHasNonEmptyDeps,
1516
"ensure-use-memo-has-non-empty-deps": ensureUseMemoHasNonEmptyDeps,
17+
"no-direct-set-state-in-use-effect": noDirectSetStateInUseEffect,
1618
"prefer-use-state-lazy-initialization": preferUseStateLazyInitialization,
1719
} as const;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { allValid, ruleTester } from "../../../../../test";
2+
import rule, { RULE_NAME } from "./no-direct-set-state-in-use-effect";
3+
4+
ruleTester.run(RULE_NAME, rule, {
5+
invalid: [
6+
{
7+
code: /* tsx */ `
8+
import { useEffect, useState } from "react";
9+
10+
function Component() {
11+
const [data, setData] = useState(0);
12+
useEffect(() => {
13+
setData(1);
14+
}, []);
15+
}
16+
`,
17+
errors: [
18+
{ messageId: "NO_DIRECT_SET_STATE_IN_USE_EFFECT" },
19+
],
20+
},
21+
{
22+
code: /* tsx */ `
23+
import { useEffect, useState } from "react";
24+
25+
function Component() {
26+
const data = useState(0);
27+
useEffect(() => {
28+
data[1]();
29+
}, []);
30+
}
31+
`,
32+
errors: [
33+
{ messageId: "NO_DIRECT_SET_STATE_IN_USE_EFFECT" },
34+
],
35+
},
36+
{
37+
code: /* tsx */ `
38+
import { useEffect, useState } from "react";
39+
40+
function Component() {
41+
const data = useState(0);
42+
useEffect(() => {
43+
data.at(1)();
44+
}, []);
45+
}
46+
`,
47+
errors: [
48+
{ messageId: "NO_DIRECT_SET_STATE_IN_USE_EFFECT" },
49+
],
50+
},
51+
{
52+
code: /* tsx */ `
53+
import { useEffect, useState } from "react";
54+
55+
const index = 1;
56+
function Component() {
57+
const data = useState(0);
58+
useEffect(() => {
59+
data.at(index)();
60+
}, []);
61+
}
62+
`,
63+
errors: [
64+
{ messageId: "NO_DIRECT_SET_STATE_IN_USE_EFFECT" },
65+
],
66+
},
67+
{
68+
code: /* tsx */ `
69+
import { useEffect, useState } from "react";
70+
71+
const index = 1;
72+
function Component() {
73+
const data = useState(0);
74+
useEffect(() => {
75+
data[index]();
76+
}, []);
77+
}
78+
`,
79+
errors: [
80+
{ messageId: "NO_DIRECT_SET_STATE_IN_USE_EFFECT" },
81+
],
82+
},
83+
{
84+
code: /* tsx */ `
85+
import { useEffect } from "react";
86+
87+
const index = 1;
88+
function Component() {
89+
const data = useCustomState(0);
90+
useEffect(() => {
91+
data[index]();
92+
}, []);
93+
}
94+
`,
95+
errors: [
96+
{ messageId: "NO_DIRECT_SET_STATE_IN_USE_EFFECT" },
97+
],
98+
settings: {
99+
"react-x": {
100+
additionalHooks: {
101+
useState: ["useCustomState"],
102+
},
103+
},
104+
},
105+
},
106+
{
107+
code: /* tsx */ `
108+
import { useState } from "react";
109+
110+
const index = 1;
111+
function Component() {
112+
const data = useState(0);
113+
useCustomEffect(() => {
114+
data[index]();
115+
}, []);
116+
}
117+
`,
118+
errors: [
119+
{ messageId: "NO_DIRECT_SET_STATE_IN_USE_EFFECT" },
120+
],
121+
settings: {
122+
"react-x": {
123+
additionalHooks: {
124+
useEffect: ["useCustomEffect"],
125+
},
126+
},
127+
},
128+
},
129+
{
130+
code: /* tsx */ `
131+
const index = 1;
132+
function Component() {
133+
const data = useCustomState(0);
134+
useCustomEffect(() => {
135+
data[index]();
136+
}, []);
137+
}
138+
`,
139+
errors: [
140+
{ messageId: "NO_DIRECT_SET_STATE_IN_USE_EFFECT" },
141+
],
142+
settings: {
143+
"react-x": {
144+
additionalHooks: {
145+
useEffect: ["useCustomEffect"],
146+
useState: ["useCustomState"],
147+
},
148+
},
149+
},
150+
},
151+
],
152+
valid: [
153+
...allValid,
154+
/* tsx */ `
155+
import { useEffect, useState } from "react";
156+
157+
function Component() {
158+
const [data, setData] = useState(0);
159+
useEffect(() => {
160+
const handler = () => setData(1);
161+
}, []);
162+
}
163+
`,
164+
/* tsx */ `
165+
import { useEffect, useState } from "react";
166+
167+
function Component() {
168+
const [data, setData] = useState(0);
169+
useEffect(() => {
170+
fetch().then(() => setData());
171+
}, []);
172+
}
173+
`,
174+
/* tsx */ `
175+
import { useEffect, useState } from "react";
176+
177+
const Component = () => {
178+
const [data, setData] = useState();
179+
useEffect(() => {
180+
(async () => { setData() })();
181+
}, []);
182+
}
183+
`,
184+
/* tsx */ `
185+
import { useEffect, useState } from "react";
186+
187+
const Component = () => {
188+
const [data, setData] = useState();
189+
useEffect(() => {
190+
const onLoad = () => {
191+
setData();
192+
};
193+
}, []);
194+
}
195+
`,
196+
],
197+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { is, NodeType, traverseUp } from "@eslint-react/ast";
2+
import { isReactHookCallWithNameLoose, isUseEffectCall, isUseStateCall } from "@eslint-react/core";
3+
import { getESLintReactSettings } from "@eslint-react/shared";
4+
import { F, O } from "@eslint-react/tools";
5+
import type { RuleContext } from "@eslint-react/types";
6+
import { findVariable, getVariableInit } from "@eslint-react/var";
7+
import type { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
8+
import { getStaticValue } from "@typescript-eslint/utils/ast-utils";
9+
import type { ConstantCase } from "string-ts";
10+
import { match } from "ts-pattern";
11+
12+
import { createRule } from "../utils";
13+
14+
export const RULE_NAME = "no-direct-set-state-in-use-effect";
15+
16+
export type MessageID = ConstantCase<typeof RULE_NAME>;
17+
18+
export default createRule<[], MessageID>({
19+
meta: {
20+
type: "problem",
21+
docs: {
22+
description: "disallow direct calls to the 'set' function of 'useState' in 'useEffect'.",
23+
},
24+
messages: {
25+
NO_DIRECT_SET_STATE_IN_USE_EFFECT: "Do not call the set function of 'useState' directly in 'useEffect'.",
26+
},
27+
schema: [],
28+
},
29+
name: RULE_NAME,
30+
create(context) {
31+
const settings = getESLintReactSettings(context.settings);
32+
const { useEffect: useEffectAlias = [], useState: useStateAlias = [] } = settings.additionalHooks ?? {};
33+
function isUseEffectCallWithAlias(node: TSESTree.CallExpression, context: RuleContext) {
34+
return (isUseEffectCall(node, context) || useEffectAlias.some(F.flip(isReactHookCallWithNameLoose)(node)));
35+
}
36+
function isUseStateCallWithAlias(node: TSESTree.CallExpression, context: RuleContext) {
37+
return (isUseStateCall(node, context) || useStateAlias.some(F.flip(isReactHookCallWithNameLoose)(node)));
38+
}
39+
return {
40+
CallExpression(node) {
41+
const effectFunction = traverseUp(
42+
node,
43+
(n) =>
44+
n.parent?.type === NodeType.CallExpression
45+
&& isUseEffectCallWithAlias(n.parent, context),
46+
);
47+
// TODO: support detecting effect cleanup functions as well or add a separate rule for that called `no-direct-set-state-in-use-effect-cleanup`
48+
if (O.isNone(effectFunction)) return;
49+
const scope = context.sourceCode.getScope(node);
50+
if (scope.block !== effectFunction.value) return;
51+
const name = match(node.callee)
52+
// const [data, setData] = useState();
53+
// setData();
54+
.with({ type: NodeType.Identifier }, (n) => O.some(n.name))
55+
// const data = useState();
56+
// data[1]();
57+
.with({ type: NodeType.MemberExpression }, (n) => {
58+
if (!("name" in n.object)) return O.none();
59+
const initialScope = context.sourceCode.getScope(n);
60+
const property = getStaticValue(n.property, initialScope);
61+
if (property?.value === 1) return O.fromNullable(n.object.name);
62+
return O.none();
63+
})
64+
// const data = useState();
65+
// data.at(1)();
66+
.with({ type: NodeType.CallExpression }, (n) => {
67+
if (!is(NodeType.MemberExpression)(n.callee)) return O.none();
68+
if (!("name" in n.callee.object)) return O.none();
69+
const isAt = match(n.callee)
70+
.with(
71+
{
72+
type: NodeType.MemberExpression,
73+
property: {
74+
type: NodeType.Identifier,
75+
name: "at",
76+
},
77+
},
78+
F.constTrue,
79+
)
80+
.otherwise(F.constFalse);
81+
if (!isAt) return O.none();
82+
const [index] = n.arguments;
83+
if (!index) return O.none();
84+
const initialScope = context.sourceCode.getScope(n);
85+
const value = getStaticValue(index, initialScope);
86+
if (value?.value === 1) return O.fromNullable(n.callee.object.name);
87+
return O.none();
88+
})
89+
.otherwise(O.none);
90+
F.pipe(
91+
name,
92+
O.flatMap(findVariable(scope)),
93+
O.flatMap(getVariableInit(0)),
94+
O.filter(is(NodeType.CallExpression)),
95+
O.filter(name => isUseStateCallWithAlias(name, context)),
96+
O.map(name => ({
97+
data: {
98+
setState: name,
99+
},
100+
messageId: "NO_DIRECT_SET_STATE_IN_USE_EFFECT",
101+
node,
102+
} as const)),
103+
O.map(context.report),
104+
);
105+
},
106+
};
107+
},
108+
defaultOptions: [],
109+
}) satisfies ESLintUtils.RuleModule<MessageID>;

website/pages/docs/glossary.mdx

+4
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ A React component that lets you group elements without a wrapper node.
2525

2626
The shorthand syntax for a Fragment in JSX. It looks like `<>...</>`.
2727

28+
### Set Function
29+
30+
The [`set` function](https://react.dev/reference/react/useState#setstate) like `setSomething(nextState)` [returned by `useState`](https://react.dev/reference/react/useState#returns) lets you update the state to a different value and trigger a re-render.
31+
2832
### Legacy React APIs
2933

3034
[APIs](https://react.dev/reference/react/legacy) are exported from the `react` package, but they are not recommended for use in newly written code.

website/pages/docs/rules/_meta.ts

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default {
7373
"hooks-extra-ensure-custom-hooks-using-other-hooks": "hooks-extra/ensure-custom-hooks-using-other-hooks",
7474
"hooks-extra-ensure-use-callback-has-non-empty-deps": "hooks-extra/ensure-use-callback-has-non-empty-deps",
7575
"hooks-extra-ensure-use-memo-has-non-empty-deps": "hooks-extra/ensure-use-memo-has-non-empty-deps",
76+
"hooks-extra-no-direct-set-state-in-use-effect": "hooks-extra/no-direct-set-state-in-use-effect",
7677
"hooks-extra-prefer-use-state-lazy-initialization": "hooks-extra/prefer-use-state-lazy-initialization",
7778
"naming-convention-component-name": "naming-convention/component-name",
7879
"naming-convention-filename": "naming-convention/filename",

0 commit comments

Comments
 (0)