Skip to content

Commit 3edcf3b

Browse files
committed
Flag using state as event handler
#17
1 parent 6cae8e1 commit 3edcf3b

File tree

7 files changed

+190
-75
lines changed

7 files changed

+190
-75
lines changed

src/messages.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,7 @@ export const messageIds = {
55
avoidChainingState: "avoidChainingState",
66
avoidParentChildCoupling: "avoidParentChildCoupling",
77
avoidResettingStateFromProps: "avoidResettingStateFromProps",
8-
// TODO: This would be nice, but I'm not sure it can be done accurately
9-
// Maybe we can accurately warn about this when the state being reacted to is one of our own `useState`s?
10-
// Because if we have a setter then we have a callback.
11-
// But, I think that would also warn about valid uses that synchronize internal state to external state.
12-
// avoidEventHandler: "avoidEventHandler",
8+
avoidEventHandler: "avoidEventHandler",
139
// TODO: Possible to detect when `useSyncExternalStore` should be preferred?
1410
};
1511

@@ -26,6 +22,6 @@ export const messages = {
2622
"Avoid coupling parent behavior or state to a child component. Instead, lift shared logic or state up to the parent.",
2723
[messageIds.avoidResettingStateFromProps]:
2824
'Avoid resetting state from props. If "{{prop}}" is a key, pass it as `key` instead so React will reset the component.',
29-
// [messages.avoidEventHandler]:
30-
// "Avoid using state as an event handler. Instead, call the event handler directly.",
25+
[messageIds.avoidEventHandler]:
26+
"Avoid using state as an event handler. Instead, call the event handler directly.",
3127
};

src/rule.js

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { messageIds, messages } from "./messages.js";
2-
import { getCallExpr, getDownstreamRefs } from "./util/ast.js";
2+
import {
3+
findDownstreamIfs,
4+
getCallExpr,
5+
getDownstreamRefs,
6+
} from "./util/ast.js";
37
import {
48
findPropUsedToResetAllState,
59
isUseEffect,
@@ -14,6 +18,7 @@ import {
1418
isProp,
1519
isHOCProp,
1620
countStateSetterCalls,
21+
isFnRef,
1722
} from "./util/react.js";
1823
import { arraysEqual } from "./util/javascript.js";
1924

@@ -67,14 +72,38 @@ export const rule = {
6772
return;
6873
}
6974

70-
effectFnRefs
71-
.filter(
72-
(ref) =>
73-
isStateSetter(context, ref) ||
74-
(isPropCallback(context, ref) &&
75-
// Don't analyze HOC prop callbacks -- we don't have control over them to lift state or logic
76-
!isHOCProp(ref.resolved)),
75+
const isAllDepsInternal = depsRefs
76+
.flatMap((ref) => getUpstreamReactVariables(context, ref.identifier))
77+
.notEmptyEvery(
78+
(variable) =>
79+
isState(variable) || (isProp(variable) && !isHOCProp(variable)),
80+
);
81+
82+
findDownstreamIfs(context, node)
83+
// An event-handling effect (invalid) and a synchronizing effect (valid)
84+
// look quite similar. But the latter should act on *all* possible states,
85+
// whereas the former waits for a specific state (from the event).
86+
// Technically synchronizing effects can be inlined too.
87+
// But an effect is arguably more readable (for once), and recommended by the React docs.
88+
.filter((ifNode) => !ifNode.alternate)
89+
.filter((ifNode) =>
90+
getDownstreamRefs(context, ifNode.test)
91+
.flatMap((ref) =>
92+
getUpstreamReactVariables(context, ref.identifier),
93+
)
94+
// TODO: Include non-HOC props, but probably with a different message -
95+
// the state would need to be lifted to inline the effect logic
96+
.notEmptyEvery((variable) => isState(variable)),
7797
)
98+
.forEach((ifNode) => {
99+
context.report({
100+
node: ifNode.test,
101+
messageId: messageIds.avoidEventHandler,
102+
});
103+
});
104+
105+
effectFnRefs
106+
.filter(isFnRef)
78107
// Non-direct calls are likely inside a callback passed to an external system like `window.addEventListener`,
79108
// or a Promise chain that (probably) retrieves external data.
80109
// Note we'll still analyze derived setters because isStateSetter considers that.
@@ -135,15 +164,6 @@ export const rule = {
135164
),
136165
),
137166
);
138-
const isAllDepsInternal = depsRefs
139-
.flatMap((ref) =>
140-
getUpstreamReactVariables(context, ref.identifier),
141-
)
142-
.notEmptyEvery(
143-
(variable) =>
144-
isState(variable) ||
145-
(isProp(variable) && !isHOCProp(variable)),
146-
);
147167

148168
if (
149169
isAllArgsInternal ||
@@ -170,20 +190,15 @@ export const rule = {
170190
messageId: messageIds.avoidChainingState,
171191
});
172192
}
173-
} else if (isPropCallback(context, ref)) {
193+
} else if (
194+
isPropCallback(context, ref) &&
195+
// Don't analyze HOC prop callbacks -- we don't have control over them to lift state or logic
196+
!isHOCProp(ref.resolved)
197+
) {
174198
// I'm pretty sure we can flag this regardless of the arguments, including none...
175-
//
176199
// Because we are either:
177200
// 1. Passing live state updates to the parent
178201
// 2. Using state as an event handler to pass final state to the parent
179-
//
180-
// Both are bad. However I'm not yet sure how we could differentiate #2 to give a better warning.
181-
//
182-
// TODO: Can we thus safely assume that state is used as an event handler when the ref is a prop?
183-
// Normally we can't warn about that because we don't know what the event handler does externally.
184-
// But when it's a prop, it's internal.
185-
// I guess it could still be valid when the dep is external state? Or in that case,
186-
// the issue is the state should be lifted to the parent?
187202
context.report({
188203
node: callExpr,
189204
messageId: messageIds.avoidParentChildCoupling,

src/util/ast.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ const getDownstreamIdentifiers = (context, rootNode) => {
3131
return identifiers;
3232
};
3333

34+
export const findDownstreamIfs = (context, node) => {
35+
const ifs = [];
36+
traverse(context, node, (n) => {
37+
if (n.type === "IfStatement") {
38+
ifs.push(n);
39+
}
40+
});
41+
return ifs;
42+
};
43+
3444
export const getUpstreamVariables = (
3545
context,
3646
node,

test/chaining-state.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ new MyRuleTester().run("/chaining-state", {
4848
}
4949
`,
5050
errors: [
51+
{
52+
messageId: messageIds.avoidEventHandler,
53+
},
5154
{
5255
messageId: messageIds.avoidChainingState,
5356
},

test/deriving-state.test.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,6 @@ new MyRuleTester().run("/deriving-state", {
7575
},
7676
{
7777
name: "Sync external state",
78-
// Technically we could trigger the network call in `input.onChange`,
79-
// but the use of an effect to sync state is arguably more readable and a valid use.
80-
// Especially when we already store the input's controlled state.
8178
code: js`
8279
function Search() {
8380
const [query, setQuery] = useState();

test/real-world.test.js

Lines changed: 39 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -178,40 +178,6 @@ new MyRuleTester().run("/real-world", {
178178
}
179179
`,
180180
},
181-
{
182-
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/7
183-
name: "Klarna",
184-
code: js`
185-
function Klarna({ klarnaAppId }) {
186-
const [countryCode] = useState(qs.parse('countryCode=meow'));
187-
const [result, setResult] = useState();
188-
const klarnaEnabled = useSelector('idk') && shouldKlarnaBeEnabled(countryCode);
189-
const currentLocale = getCurrentLocale(useGetCurrentLanguage());
190-
191-
const loadSignInWithKlarna = (klarnaAppId, klarnaEnvironment, countryCode, currentLocale) => {
192-
const klarnaResult = doSomething();
193-
setResult(klarnaResult);
194-
};
195-
196-
useEffect(() => {
197-
if (klarnaEnabled) {
198-
return loadSignInWithKlarna(
199-
klarnaAppId,
200-
klarnaEnvironment,
201-
countryCode?.toUpperCase(),
202-
currentLocale,
203-
);
204-
}
205-
}, [
206-
countryCode,
207-
klarnaAppId,
208-
klarnaEnabled,
209-
klarnaEnvironment,
210-
currentLocale,
211-
]);
212-
}
213-
`,
214-
},
215181
{
216182
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/10
217183
name: "navigation.setOptions",
@@ -335,5 +301,44 @@ new MyRuleTester().run("/real-world", {
335301
},
336302
],
337303
},
304+
{
305+
// https://github.com/NickvanDyke/eslint-plugin-react-you-might-not-need-an-effect/issues/7
306+
name: "Klarna",
307+
code: js`
308+
function Klarna({ klarnaAppId }) {
309+
const [countryCode] = useState(qs.parse('countryCode=meow'));
310+
const [result, setResult] = useState();
311+
const klarnaEnabled = useSelector('idk') && shouldKlarnaBeEnabled(countryCode);
312+
const currentLocale = getCurrentLocale(useGetCurrentLanguage());
313+
314+
const loadSignInWithKlarna = (klarnaAppId, klarnaEnvironment, countryCode, currentLocale) => {
315+
const klarnaResult = doSomething();
316+
setResult(klarnaResult);
317+
};
318+
319+
useEffect(() => {
320+
if (klarnaEnabled) {
321+
return loadSignInWithKlarna(
322+
klarnaAppId,
323+
klarnaEnvironment,
324+
countryCode?.toUpperCase(),
325+
currentLocale,
326+
);
327+
}
328+
}, [
329+
countryCode,
330+
klarnaAppId,
331+
klarnaEnabled,
332+
klarnaEnvironment,
333+
currentLocale,
334+
]);
335+
}
336+
`,
337+
errors: [
338+
{
339+
messageId: messageIds.avoidEventHandler,
340+
},
341+
],
342+
},
338343
],
339344
});

test/using-state-as-event-handler.test.js

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,108 @@ import { MyRuleTester, js } from "./rule-tester.js";
22
import { messageIds } from "../src/messages.js";
33

44
new MyRuleTester().run("/using-state-as-event-handler", {
5+
valid: [
6+
{
7+
name: "Sychronizing with external system",
8+
code: js`
9+
function Search() {
10+
const [query, setQuery] = useState();
11+
const [results, setResults] = useState();
12+
13+
useEffect(() => {
14+
fetch('/search?query=' + query).then((data) => {
15+
setResults(data);
16+
});
17+
}, [query]);
18+
19+
return (
20+
<div>
21+
<input
22+
name="query"
23+
type="text"
24+
value={query}
25+
onChange={(e) => setQuery(e.target.value)}
26+
/>
27+
<ul>
28+
{results.map((result) => (
29+
<li key={result.id}>{result.title}</li>
30+
))}
31+
</ul>
32+
</div>
33+
)
34+
}
35+
`,
36+
},
37+
{
38+
name: "If test includes non-state",
39+
code: js`
40+
function Form() {
41+
const [name, setName] = useState();
42+
const [dataToSubmit, setDataToSubmit] = useState();
43+
44+
useEffect(() => {
45+
if (dataToSubmit && Date.now() % 2 === 0) {
46+
submitData(dataToSubmit);
47+
}
48+
}, [dataToSubmit]);
49+
50+
return (
51+
<div>
52+
<input
53+
name="name"
54+
type="text"
55+
onChange={(e) => setName(e.target.value)}
56+
/>
57+
<button onClick={() => setDataToSubmit({ name })}>Submit</button>
58+
</div>
59+
)
60+
}
61+
`,
62+
},
63+
],
564
invalid: [
665
{
7-
// TODO: How to detect this though? Not sure it's discernable from legit synchronization effects.
8-
// Maybe when the setter is only called in this one place? Meaning we could instead inline the effect.
966
name: "Using state to handle an event",
10-
todo: true,
1167
code: js`
1268
function Form() {
1369
const [name, setName] = useState();
1470
const [dataToSubmit, setDataToSubmit] = useState();
1571
1672
useEffect(() => {
17-
submitData(dataToSubmit);
73+
if (dataToSubmit) {
74+
submitData(dataToSubmit);
75+
}
76+
}, [dataToSubmit]);
77+
78+
return (
79+
<div>
80+
<input
81+
name="name"
82+
type="text"
83+
onChange={(e) => setName(e.target.value)}
84+
/>
85+
<button onClick={() => setDataToSubmit({ name })}>Submit</button>
86+
</div>
87+
)
88+
}
89+
`,
90+
errors: [
91+
{
92+
messageId: messageIds.avoidEventHandler,
93+
},
94+
],
95+
},
96+
{
97+
name: "More complex if test",
98+
code: js`
99+
function Form() {
100+
const [name, setName] = useState();
101+
const [dataToSubmit, setDataToSubmit] = useState();
102+
103+
useEffect(() => {
104+
if (dataToSubmit.name && dataToSubmit.name.length > 0) {
105+
submitData(dataToSubmit);
106+
}
18107
}, [dataToSubmit]);
19108
20109
return (

0 commit comments

Comments
 (0)