Skip to content

Commit c3d3e25

Browse files
metreniukljharb
authored andcommitted
[New] hook-use-state: add allowDestructuredState option
1 parent 96062ea commit c3d3e25

File tree

4 files changed

+229
-116
lines changed

4 files changed

+229
-116
lines changed

CHANGELOG.md

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

66
## Unreleased
77

8+
### Added
9+
* [`hook-use-state`]: add `allowDestructuredState` option ([#3449][] @ljharb)
10+
11+
[#3449]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3449
12+
813
## [7.31.9] - 2022.10.09
914

1015
### Fixed

docs/rules/hook-use-state.md

+19
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,22 @@ export default function useColor() {
4848
return React.useState();
4949
}
5050
```
51+
52+
## Rule Options
53+
54+
```js
55+
...
56+
"react/hook-use-state": [<enabled>, { "allowDestructuredState": <boolean> }]
57+
...
58+
```
59+
60+
### `allowDestructuredState`
61+
62+
When `true` the rule will ignore the name of the destructured value.
63+
64+
Examples of **correct** code for this rule, when configured with `{ "allowDestructuredState": true }`:
65+
66+
```jsx
67+
import React from 'react';
68+
const [{foo, bar, baz}, setFooBarBaz] = React.useState({foo: "bbb", bar: "aaa", baz: "qqq"})
69+
```

lib/rules/hook-use-state.js

+152-116
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@ const report = require('../util/report');
1313
// Rule Definition
1414
// ------------------------------------------------------------------------------
1515

16+
function isNodeDestructuring(node) {
17+
return node && (node.type === 'ArrayPattern' || node.type === 'ObjectPattern');
18+
}
19+
1620
const messages = {
1721
useStateErrorMessage: 'useState call is not destructured into value + setter pair',
22+
useStateErrorMessageOrAddOption: 'useState call is not destructured into value + setter pair (you can allow destructuring by enabling "allowDestructuredState" option)',
1823
};
1924

2025
module.exports = {
@@ -26,135 +31,166 @@ module.exports = {
2631
url: docsUrl('hook-use-state'),
2732
},
2833
messages,
29-
schema: [],
34+
schema: [{
35+
type: 'object',
36+
properties: {
37+
allowDestructuredState: {
38+
default: false,
39+
type: 'boolean',
40+
},
41+
},
42+
additionalProperties: false,
43+
}],
3044
type: 'suggestion',
3145
hasSuggestions: true,
3246
},
3347

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 caseCandidateMatch = valueVariableName ? valueVariableName.match(/(^[a-z]+)(.*)/) : undefined;
70-
const upperCaseCandidatePrefix = caseCandidateMatch ? caseCandidateMatch[1] : undefined;
71-
const caseCandidateSuffix = caseCandidateMatch ? caseCandidateMatch[2] : undefined;
72-
const expectedSetterVariableNames = upperCaseCandidatePrefix ? [
73-
`set${upperCaseCandidatePrefix.charAt(0).toUpperCase()}${upperCaseCandidatePrefix.slice(1)}${caseCandidateSuffix}`,
74-
`set${upperCaseCandidatePrefix.toUpperCase()}${caseCandidateSuffix}`,
75-
] : [];
76-
77-
const isSymmetricGetterSetterPair = valueVariable
78-
&& setterVariable
79-
&& expectedSetterVariableNames.indexOf(setterVariableName) !== -1
80-
&& variableNodes.length === 2;
81-
82-
if (!isSymmetricGetterSetterPair) {
83-
const suggestions = [
84-
{
85-
desc: 'Destructure useState call into value + setter pair',
86-
fix: (fixer) => {
87-
if (expectedSetterVariableNames.length === 0) {
88-
return;
89-
}
48+
create: Components.detect((context, components, util) => {
49+
const configuration = context.options[0] || {};
50+
const allowDestructuredState = configuration.allowDestructuredState || false;
9051

91-
const fix = fixer.replaceTextRange(
92-
node.parent.id.range,
93-
`[${valueVariableName}, ${expectedSetterVariableNames[0]}]`
94-
);
52+
return {
53+
CallExpression(node) {
54+
const isImmediateReturn = node.parent
55+
&& node.parent.type === 'ReturnStatement';
9556

96-
return fix;
97-
},
98-
},
99-
];
57+
if (isImmediateReturn || !util.isReactHookCall(node, ['useState'])) {
58+
return;
59+
}
10060

101-
const defaultReactImports = components.getDefaultReactImports();
102-
const defaultReactImportSpecifier = defaultReactImports
103-
? defaultReactImports[0]
104-
: undefined;
61+
const isDestructuringDeclarator = node.parent
62+
&& node.parent.type === 'VariableDeclarator'
63+
&& node.parent.id.type === 'ArrayPattern';
64+
65+
if (!isDestructuringDeclarator) {
66+
report(
67+
context,
68+
messages.useStateErrorMessage,
69+
'useStateErrorMessage',
70+
{ node }
71+
);
72+
return;
73+
}
74+
75+
const variableNodes = node.parent.id.elements;
76+
const valueVariable = variableNodes[0];
77+
const setterVariable = variableNodes[1];
78+
const isOnlyValueDestructuring = isNodeDestructuring(valueVariable) && !isNodeDestructuring(setterVariable);
79+
80+
if (allowDestructuredState && isOnlyValueDestructuring) {
81+
return;
82+
}
10583

106-
const defaultReactImportName = defaultReactImportSpecifier
107-
? defaultReactImportSpecifier.local.name
84+
const valueVariableName = valueVariable
85+
? valueVariable.name
10886
: undefined;
10987

110-
const namedReactImports = components.getNamedReactImports();
111-
const useStateReactImportSpecifier = namedReactImports
112-
? namedReactImports.find((specifier) => specifier.imported.name === 'useState')
88+
const setterVariableName = setterVariable
89+
? setterVariable.name
11390
: undefined;
11491

115-
const isSingleGetter = valueVariable && variableNodes.length === 1;
116-
const isUseStateCalledWithSingleArgument = node.arguments.length === 1;
117-
if (isSingleGetter && isUseStateCalledWithSingleArgument) {
118-
const useMemoReactImportSpecifier = namedReactImports
119-
&& namedReactImports.find((specifier) => specifier.imported.name === 'useMemo');
120-
121-
let useMemoCode;
122-
if (useMemoReactImportSpecifier) {
123-
useMemoCode = useMemoReactImportSpecifier.local.name;
124-
} else if (defaultReactImportName) {
125-
useMemoCode = `${defaultReactImportName}.useMemo`;
126-
} else {
127-
useMemoCode = 'useMemo';
92+
const caseCandidateMatch = valueVariableName ? valueVariableName.match(/(^[a-z]+)(.*)/) : undefined;
93+
const upperCaseCandidatePrefix = caseCandidateMatch ? caseCandidateMatch[1] : undefined;
94+
const caseCandidateSuffix = caseCandidateMatch ? caseCandidateMatch[2] : undefined;
95+
const expectedSetterVariableNames = upperCaseCandidatePrefix ? [
96+
`set${upperCaseCandidatePrefix.charAt(0).toUpperCase()}${upperCaseCandidatePrefix.slice(1)}${caseCandidateSuffix}`,
97+
`set${upperCaseCandidatePrefix.toUpperCase()}${caseCandidateSuffix}`,
98+
] : [];
99+
100+
const isSymmetricGetterSetterPair = valueVariable
101+
&& setterVariable
102+
&& expectedSetterVariableNames.indexOf(setterVariableName) !== -1
103+
&& variableNodes.length === 2;
104+
105+
if (!isSymmetricGetterSetterPair) {
106+
const suggestions = [
107+
{
108+
desc: 'Destructure useState call into value + setter pair',
109+
fix: (fixer) => {
110+
if (expectedSetterVariableNames.length === 0) {
111+
return;
112+
}
113+
114+
const fix = fixer.replaceTextRange(
115+
node.parent.id.range,
116+
`[${valueVariableName}, ${expectedSetterVariableNames[0]}]`
117+
);
118+
119+
return fix;
120+
},
121+
},
122+
];
123+
124+
const defaultReactImports = components.getDefaultReactImports();
125+
const defaultReactImportSpecifier = defaultReactImports
126+
? defaultReactImports[0]
127+
: undefined;
128+
129+
const defaultReactImportName = defaultReactImportSpecifier
130+
? defaultReactImportSpecifier.local.name
131+
: undefined;
132+
133+
const namedReactImports = components.getNamedReactImports();
134+
const useStateReactImportSpecifier = namedReactImports
135+
? namedReactImports.find((specifier) => specifier.imported.name === 'useState')
136+
: undefined;
137+
138+
const isSingleGetter = valueVariable && variableNodes.length === 1;
139+
const isUseStateCalledWithSingleArgument = node.arguments.length === 1;
140+
if (isSingleGetter && isUseStateCalledWithSingleArgument) {
141+
const useMemoReactImportSpecifier = namedReactImports
142+
&& namedReactImports.find((specifier) => specifier.imported.name === 'useMemo');
143+
144+
let useMemoCode;
145+
if (useMemoReactImportSpecifier) {
146+
useMemoCode = useMemoReactImportSpecifier.local.name;
147+
} else if (defaultReactImportName) {
148+
useMemoCode = `${defaultReactImportName}.useMemo`;
149+
} else {
150+
useMemoCode = 'useMemo';
151+
}
152+
153+
suggestions.unshift({
154+
desc: 'Replace useState call with useMemo',
155+
fix: (fixer) => [
156+
// Add useMemo import, if necessary
157+
useStateReactImportSpecifier
158+
&& (!useMemoReactImportSpecifier || defaultReactImportName)
159+
&& fixer.insertTextAfter(useStateReactImportSpecifier, ', useMemo'),
160+
// Convert single-value destructure to simple assignment
161+
fixer.replaceTextRange(node.parent.id.range, valueVariableName),
162+
// Convert useState call to useMemo + arrow function + dependency array
163+
fixer.replaceTextRange(
164+
node.range,
165+
`${useMemoCode}(() => ${context.getSourceCode().getText(node.arguments[0])}, [])`
166+
),
167+
].filter(Boolean),
168+
});
128169
}
129170

130-
suggestions.unshift({
131-
desc: 'Replace useState call with useMemo',
132-
fix: (fixer) => [
133-
// Add useMemo import, if necessary
134-
useStateReactImportSpecifier
135-
&& (!useMemoReactImportSpecifier || defaultReactImportName)
136-
&& fixer.insertTextAfter(useStateReactImportSpecifier, ', useMemo'),
137-
// Convert single-value destructure to simple assignment
138-
fixer.replaceTextRange(node.parent.id.range, valueVariableName),
139-
// Convert useState call to useMemo + arrow function + dependency array
140-
fixer.replaceTextRange(
141-
node.range,
142-
`${useMemoCode}(() => ${context.getSourceCode().getText(node.arguments[0])}, [])`
143-
),
144-
].filter(Boolean),
145-
});
146-
}
147-
148-
report(
149-
context,
150-
messages.useStateErrorMessage,
151-
'useStateErrorMessage',
152-
{
153-
node: node.parent.id,
154-
suggest: suggestions,
171+
if (isOnlyValueDestructuring) {
172+
report(
173+
context,
174+
messages.useStateErrorMessageOrAddOption,
175+
'useStateErrorMessageOrAddOption',
176+
{
177+
node: node.parent.id,
178+
}
179+
);
180+
return;
155181
}
156-
);
157-
}
158-
},
159-
})),
182+
183+
report(
184+
context,
185+
messages.useStateErrorMessage,
186+
'useStateErrorMessage',
187+
{
188+
node: node.parent.id,
189+
suggest: suggestions,
190+
}
191+
);
192+
}
193+
},
194+
};
195+
}),
160196
};

tests/lib/rules/hook-use-state.js

+53
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,22 @@ const tests = {
177177
`,
178178
features: ['ts'],
179179
},
180+
{
181+
code: `
182+
import { useState } from 'react';
183+
184+
const [{foo, bar, baz}, setFooBarBaz] = useState({foo: "bbb", bar: "aaa", baz: "qqq"})
185+
`,
186+
options: [{ allowDestructuredState: true }],
187+
},
188+
{
189+
code: `
190+
import { useState } from 'react';
191+
192+
const [[index, value], setValueWithIndex] = useState([0, "hello"])
193+
`,
194+
options: [{ allowDestructuredState: true }],
195+
},
180196
]),
181197
invalid: parsers.all([
182198
{
@@ -498,6 +514,43 @@ const tests = {
498514
},
499515
],
500516
},
517+
{
518+
code: `
519+
import { useState } from 'react';
520+
521+
const [{foo, bar, baz}, setFooBarBaz] = useState({foo: "bbb", bar: "aaa", baz: "qqq"})
522+
`,
523+
errors: [
524+
{
525+
message: 'useState call is not destructured into value + setter pair (you can allow destructuring by enabling "allowDestructuredState" option)',
526+
},
527+
],
528+
},
529+
{
530+
code: `
531+
import { useState } from 'react';
532+
533+
const [[index, value], setValueWithIndex] = useState([0, "hello"])
534+
`,
535+
errors: [
536+
{
537+
message: 'useState call is not destructured into value + setter pair (you can allow destructuring by enabling "allowDestructuredState" option)',
538+
},
539+
],
540+
},
541+
{
542+
code: `
543+
import { useState } from 'react';
544+
545+
const [{foo, bar, baz}, {setFooBarBaz}] = useState({foo: "bbb", bar: "aaa", baz: "qqq"})
546+
`,
547+
options: [{ allowDestructuredState: true }],
548+
errors: [
549+
{
550+
message: 'useState call is not destructured into value + setter pair',
551+
},
552+
],
553+
},
501554
{
502555
code: `
503556
import { useState } from 'react'

0 commit comments

Comments
 (0)