Skip to content

Commit 4f54108

Browse files
duncanbeeversljharb
authored andcommitted
[New] add hook-use-state rule to enforce symmetric useState hook variable names
Ensure two symmetrically-named variables are destructured from useState hook calls
1 parent 9be55ed commit 4f54108

File tree

6 files changed

+756
-1
lines changed

6 files changed

+756
-1
lines changed

CHANGELOG.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
55

66
## Unreleased
77

8+
### Added
9+
* add [`hook-use-state`] rule to enforce symmetric useState hook variable names ([#2921][] @duncanbeevers)
10+
11+
[#2921]: https://github.com/yannickcr/eslint-plugin-react/pull/2921
12+
813
## [7.28.0] - 2021.12.22
914

1015
### Added
@@ -3498,6 +3503,7 @@ If you're still not using React 15 you can keep the old behavior by setting the
34983503
[`forbid-foreign-prop-types`]: docs/rules/forbid-foreign-prop-types.md
34993504
[`forbid-prop-types`]: docs/rules/forbid-prop-types.md
35003505
[`function-component-definition`]: docs/rules/function-component-definition.md
3506+
[`hook-use-state`]: docs/rules/hook-use-state.md
35013507
[`jsx-boolean-value`]: docs/rules/jsx-boolean-value.md
35023508
[`jsx-child-element-spacing`]: docs/rules/jsx-child-element-spacing.md
35033509
[`jsx-closing-bracket-location`]: docs/rules/jsx-closing-bracket-location.md
@@ -3551,6 +3557,7 @@ If you're still not using React 15 you can keep the old behavior by setting the
35513557
[`no-did-update-set-state`]: docs/rules/no-did-update-set-state.md
35523558
[`no-direct-mutation-state`]: docs/rules/no-direct-mutation-state.md
35533559
[`no-find-dom-node`]: docs/rules/no-find-dom-node.md
3560+
[`no-invalid-html-attribute`]: docs/rules/no-invalid-html-attribute.md
35543561
[`no-is-mounted`]: docs/rules/no-is-mounted.md
35553562
[`no-multi-comp`]: docs/rules/no-multi-comp.md
35563563
[`no-namespace`]: docs/rules/no-namespace.md
@@ -3586,4 +3593,3 @@ If you're still not using React 15 you can keep the old behavior by setting the
35863593
[`style-prop-object`]: docs/rules/style-prop-object.md
35873594
[`void-dom-elements-no-children`]: docs/rules/void-dom-elements-no-children.md
35883595
[`wrap-multilines`]: docs/rules/jsx-wrap-multilines.md
3589-
[`no-invalid-html-attribute`]: docs/rules/no-invalid-html-attribute.md

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Enable the rules that you would like to use.
132132
| | | [react/forbid-foreign-prop-types](docs/rules/forbid-foreign-prop-types.md) | Forbid using another component's propTypes |
133133
| | | [react/forbid-prop-types](docs/rules/forbid-prop-types.md) | Forbid certain propTypes |
134134
| | 🔧 | [react/function-component-definition](docs/rules/function-component-definition.md) | Standardize the way function component get defined |
135+
| | | [react/hook-use-state](docs/rules/hook-use-state.md) | Ensure symmetric naming of useState hook value and setter variables |
135136
| | | [react/no-access-state-in-setstate](docs/rules/no-access-state-in-setstate.md) | Reports when this.state is accessed within setState |
136137
| | | [react/no-adjacent-inline-elements](docs/rules/no-adjacent-inline-elements.md) | Prevent adjacent inline elements not separated by whitespace. |
137138
| | | [react/no-array-index-key](docs/rules/no-array-index-key.md) | Prevent usage of Array index in keys |

docs/rules/hook-use-state.md

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Ensure destructuring and symmetric naming of useState hook value and setter variables (react/hook-use-state)
2+
3+
## Rule Details
4+
5+
This rule checks whether the value and setter variables destructured from a `React.useState()` call are named symmetrically.
6+
7+
Examples of **incorrect** code for this rule:
8+
9+
```js
10+
import React from 'react';
11+
export default function useColor() {
12+
// useState call is not destructured into value + setter pair
13+
const useStateResult = React.useState();
14+
return useStateResult;
15+
}
16+
```
17+
18+
```js
19+
import React from 'react';
20+
export default function useColor() {
21+
// useState call is destructured into value + setter pair, but identifier
22+
// names do not follow the [thing, setThing] naming convention
23+
const [color, updateColor] = React.useState();
24+
return useStateResult;
25+
}
26+
```
27+
28+
Examples of **correct** code for this rule:
29+
30+
```js
31+
import React from 'react';
32+
export default function useColor() {
33+
// useState call is destructured into value + setter pair whose identifiers
34+
// follow the [thing, setThing] naming convention
35+
const [color, setColor] = React.useState();
36+
return [color, setColor];
37+
}
38+
```
39+
40+
```js
41+
import React from 'react';
42+
export default function useColor() {
43+
// useState result is directly returned
44+
return React.useState();
45+
}
46+
```

index.js

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const allRules = {
1616
'forbid-foreign-prop-types': require('./lib/rules/forbid-foreign-prop-types'),
1717
'forbid-prop-types': require('./lib/rules/forbid-prop-types'),
1818
'function-component-definition': require('./lib/rules/function-component-definition'),
19+
'hook-use-state': require('./lib/rules/hook-use-state'),
1920
'jsx-boolean-value': require('./lib/rules/jsx-boolean-value'),
2021
'jsx-child-element-spacing': require('./lib/rules/jsx-child-element-spacing'),
2122
'jsx-closing-bracket-location': require('./lib/rules/jsx-closing-bracket-location'),

lib/rules/hook-use-state.js

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* @fileoverview Ensure symmetric naming of useState hook value and setter variables
3+
* @author Duncan Beevers
4+
*/
5+
6+
'use strict';
7+
8+
const Components = require('../util/Components');
9+
const docsUrl = require('../util/docsUrl');
10+
const report = require('../util/report');
11+
12+
// ------------------------------------------------------------------------------
13+
// Rule Definition
14+
// ------------------------------------------------------------------------------
15+
16+
const messages = {
17+
useStateErrorMessage: 'useState call is not destructured into value + setter pair',
18+
};
19+
20+
module.exports = {
21+
meta: {
22+
docs: {
23+
description: 'Ensure symmetric naming of useState hook value and setter variables',
24+
category: 'Best Practices',
25+
recommended: false,
26+
url: docsUrl('hook-use-state'),
27+
},
28+
messages,
29+
schema: [],
30+
type: 'suggestion',
31+
hasSuggestions: true,
32+
},
33+
34+
create: Components.detect((context, components, util) => ({
35+
CallExpression(node) {
36+
const isImmediateReturn = node.parent
37+
&& node.parent.type === 'ReturnStatement';
38+
39+
if (isImmediateReturn || !util.isReactHookCall(node, ['useState'])) {
40+
return;
41+
}
42+
43+
const isDestructuringDeclarator = node.parent
44+
&& node.parent.type === 'VariableDeclarator'
45+
&& node.parent.id.type === 'ArrayPattern';
46+
47+
if (!isDestructuringDeclarator) {
48+
report(
49+
context,
50+
messages.useStateErrorMessage,
51+
'useStateErrorMessage',
52+
{ node }
53+
);
54+
return;
55+
}
56+
57+
const variableNodes = node.parent.id.elements;
58+
const valueVariable = variableNodes[0];
59+
const setterVariable = variableNodes[1];
60+
61+
const valueVariableName = valueVariable
62+
? valueVariable.name
63+
: undefined;
64+
65+
const setterVariableName = setterVariable
66+
? setterVariable.name
67+
: undefined;
68+
69+
const expectedSetterVariableName = valueVariableName ? (
70+
`set${valueVariableName.charAt(0).toUpperCase()}${valueVariableName.slice(1)}`
71+
) : undefined;
72+
73+
const isSymmetricGetterSetterPair = valueVariable
74+
&& setterVariable
75+
&& setterVariableName === expectedSetterVariableName
76+
&& variableNodes.length === 2;
77+
78+
if (!isSymmetricGetterSetterPair) {
79+
const suggestions = [
80+
{
81+
desc: 'Destructure useState call into value + setter pair',
82+
fix: (fixer) => {
83+
const fix = fixer.replaceTextRange(
84+
node.parent.id.range,
85+
`[${valueVariableName}, ${expectedSetterVariableName}]`
86+
);
87+
88+
return fix;
89+
},
90+
},
91+
];
92+
93+
const defaultReactImports = components.getDefaultReactImports();
94+
const defaultReactImportSpecifier = defaultReactImports
95+
? defaultReactImports[0]
96+
: undefined;
97+
98+
const defaultReactImportName = defaultReactImportSpecifier
99+
? defaultReactImportSpecifier.local.name
100+
: undefined;
101+
102+
const namedReactImports = components.getNamedReactImports();
103+
const useStateReactImportSpecifier = namedReactImports
104+
? namedReactImports.find((specifier) => specifier.imported.name === 'useState')
105+
: undefined;
106+
107+
const isSingleGetter = valueVariable && variableNodes.length === 1;
108+
const isUseStateCalledWithSingleArgument = node.arguments.length === 1;
109+
if (isSingleGetter && isUseStateCalledWithSingleArgument) {
110+
const useMemoReactImportSpecifier = namedReactImports
111+
&& namedReactImports.find((specifier) => specifier.imported.name === 'useMemo');
112+
113+
let useMemoCode;
114+
if (useMemoReactImportSpecifier) {
115+
useMemoCode = useMemoReactImportSpecifier.local.name;
116+
} else if (defaultReactImportName) {
117+
useMemoCode = `${defaultReactImportName}.useMemo`;
118+
} else {
119+
useMemoCode = 'useMemo';
120+
}
121+
122+
suggestions.unshift({
123+
desc: 'Replace useState call with useMemo',
124+
fix: (fixer) => [
125+
// Add useMemo import, if necessary
126+
useStateReactImportSpecifier
127+
&& (!useMemoReactImportSpecifier || defaultReactImportName)
128+
&& fixer.insertTextAfter(useStateReactImportSpecifier, ', useMemo'),
129+
// Convert single-value destructure to simple assignment
130+
fixer.replaceTextRange(node.parent.id.range, valueVariableName),
131+
// Convert useState call to useMemo + arrow function + dependency array
132+
fixer.replaceTextRange(
133+
node.range,
134+
`${useMemoCode}(() => ${context.getSourceCode().getText(node.arguments[0])}, [])`
135+
),
136+
].filter(Boolean),
137+
});
138+
}
139+
140+
report(
141+
context,
142+
messages.useStateErrorMessage,
143+
'useStateErrorMessage',
144+
{
145+
node: node.parent.id,
146+
suggest: suggestions,
147+
}
148+
);
149+
}
150+
},
151+
})),
152+
};

0 commit comments

Comments
 (0)