Skip to content

Commit

Permalink
[New] add hook-use-state rule to enforce symmetric useState hook va…
Browse files Browse the repository at this point in the history
…riable names

Ensure two symmetrically-named variables are destructured from useState hook calls
  • Loading branch information
duncanbeevers authored and ljharb committed Dec 9, 2020
1 parent 9be55ed commit 4f54108
Show file tree
Hide file tree
Showing 6 changed files with 756 additions and 1 deletion.
8 changes: 7 additions & 1 deletion CHANGELOG.md
Expand Up @@ -5,6 +5,11 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel

## Unreleased

### Added
* add [`hook-use-state`] rule to enforce symmetric useState hook variable names ([#2921][] @duncanbeevers)

[#2921]: https://github.com/yannickcr/eslint-plugin-react/pull/2921

## [7.28.0] - 2021.12.22

### Added
Expand Down Expand Up @@ -3498,6 +3503,7 @@ If you're still not using React 15 you can keep the old behavior by setting the
[`forbid-foreign-prop-types`]: docs/rules/forbid-foreign-prop-types.md
[`forbid-prop-types`]: docs/rules/forbid-prop-types.md
[`function-component-definition`]: docs/rules/function-component-definition.md
[`hook-use-state`]: docs/rules/hook-use-state.md
[`jsx-boolean-value`]: docs/rules/jsx-boolean-value.md
[`jsx-child-element-spacing`]: docs/rules/jsx-child-element-spacing.md
[`jsx-closing-bracket-location`]: docs/rules/jsx-closing-bracket-location.md
Expand Down Expand Up @@ -3551,6 +3557,7 @@ If you're still not using React 15 you can keep the old behavior by setting the
[`no-did-update-set-state`]: docs/rules/no-did-update-set-state.md
[`no-direct-mutation-state`]: docs/rules/no-direct-mutation-state.md
[`no-find-dom-node`]: docs/rules/no-find-dom-node.md
[`no-invalid-html-attribute`]: docs/rules/no-invalid-html-attribute.md
[`no-is-mounted`]: docs/rules/no-is-mounted.md
[`no-multi-comp`]: docs/rules/no-multi-comp.md
[`no-namespace`]: docs/rules/no-namespace.md
Expand Down Expand Up @@ -3586,4 +3593,3 @@ If you're still not using React 15 you can keep the old behavior by setting the
[`style-prop-object`]: docs/rules/style-prop-object.md
[`void-dom-elements-no-children`]: docs/rules/void-dom-elements-no-children.md
[`wrap-multilines`]: docs/rules/jsx-wrap-multilines.md
[`no-invalid-html-attribute`]: docs/rules/no-invalid-html-attribute.md
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -132,6 +132,7 @@ Enable the rules that you would like to use.
| | | [react/forbid-foreign-prop-types](docs/rules/forbid-foreign-prop-types.md) | Forbid using another component's propTypes |
| | | [react/forbid-prop-types](docs/rules/forbid-prop-types.md) | Forbid certain propTypes |
| | 🔧 | [react/function-component-definition](docs/rules/function-component-definition.md) | Standardize the way function component get defined |
| | | [react/hook-use-state](docs/rules/hook-use-state.md) | Ensure symmetric naming of useState hook value and setter variables |
| | | [react/no-access-state-in-setstate](docs/rules/no-access-state-in-setstate.md) | Reports when this.state is accessed within setState |
| | | [react/no-adjacent-inline-elements](docs/rules/no-adjacent-inline-elements.md) | Prevent adjacent inline elements not separated by whitespace. |
| | | [react/no-array-index-key](docs/rules/no-array-index-key.md) | Prevent usage of Array index in keys |
Expand Down
46 changes: 46 additions & 0 deletions docs/rules/hook-use-state.md
@@ -0,0 +1,46 @@
# Ensure destructuring and symmetric naming of useState hook value and setter variables (react/hook-use-state)

## Rule Details

This rule checks whether the value and setter variables destructured from a `React.useState()` call are named symmetrically.

Examples of **incorrect** code for this rule:

```js
import React from 'react';
export default function useColor() {
// useState call is not destructured into value + setter pair
const useStateResult = React.useState();
return useStateResult;
}
```

```js
import React from 'react';
export default function useColor() {
// useState call is destructured into value + setter pair, but identifier
// names do not follow the [thing, setThing] naming convention
const [color, updateColor] = React.useState();
return useStateResult;
}
```

Examples of **correct** code for this rule:

```js
import React from 'react';
export default function useColor() {
// useState call is destructured into value + setter pair whose identifiers
// follow the [thing, setThing] naming convention
const [color, setColor] = React.useState();
return [color, setColor];
}
```

```js
import React from 'react';
export default function useColor() {
// useState result is directly returned
return React.useState();
}
```
1 change: 1 addition & 0 deletions index.js
Expand Up @@ -16,6 +16,7 @@ const allRules = {
'forbid-foreign-prop-types': require('./lib/rules/forbid-foreign-prop-types'),
'forbid-prop-types': require('./lib/rules/forbid-prop-types'),
'function-component-definition': require('./lib/rules/function-component-definition'),
'hook-use-state': require('./lib/rules/hook-use-state'),
'jsx-boolean-value': require('./lib/rules/jsx-boolean-value'),
'jsx-child-element-spacing': require('./lib/rules/jsx-child-element-spacing'),
'jsx-closing-bracket-location': require('./lib/rules/jsx-closing-bracket-location'),
Expand Down
152 changes: 152 additions & 0 deletions lib/rules/hook-use-state.js
@@ -0,0 +1,152 @@
/**
* @fileoverview Ensure symmetric naming of useState hook value and setter variables
* @author Duncan Beevers
*/

'use strict';

const Components = require('../util/Components');
const docsUrl = require('../util/docsUrl');
const report = require('../util/report');

// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------

const messages = {
useStateErrorMessage: 'useState call is not destructured into value + setter pair',
};

module.exports = {
meta: {
docs: {
description: 'Ensure symmetric naming of useState hook value and setter variables',
category: 'Best Practices',
recommended: false,
url: docsUrl('hook-use-state'),
},
messages,
schema: [],
type: 'suggestion',
hasSuggestions: true,
},

create: Components.detect((context, components, util) => ({
CallExpression(node) {
const isImmediateReturn = node.parent
&& node.parent.type === 'ReturnStatement';

if (isImmediateReturn || !util.isReactHookCall(node, ['useState'])) {
return;
}

const isDestructuringDeclarator = node.parent
&& node.parent.type === 'VariableDeclarator'
&& node.parent.id.type === 'ArrayPattern';

if (!isDestructuringDeclarator) {
report(
context,
messages.useStateErrorMessage,
'useStateErrorMessage',
{ node }
);
return;
}

const variableNodes = node.parent.id.elements;
const valueVariable = variableNodes[0];
const setterVariable = variableNodes[1];

const valueVariableName = valueVariable
? valueVariable.name
: undefined;

const setterVariableName = setterVariable
? setterVariable.name
: undefined;

const expectedSetterVariableName = valueVariableName ? (
`set${valueVariableName.charAt(0).toUpperCase()}${valueVariableName.slice(1)}`
) : undefined;

const isSymmetricGetterSetterPair = valueVariable
&& setterVariable
&& setterVariableName === expectedSetterVariableName
&& variableNodes.length === 2;

if (!isSymmetricGetterSetterPair) {
const suggestions = [
{
desc: 'Destructure useState call into value + setter pair',
fix: (fixer) => {
const fix = fixer.replaceTextRange(
node.parent.id.range,
`[${valueVariableName}, ${expectedSetterVariableName}]`
);

return fix;
},
},
];

const defaultReactImports = components.getDefaultReactImports();
const defaultReactImportSpecifier = defaultReactImports
? defaultReactImports[0]
: undefined;

const defaultReactImportName = defaultReactImportSpecifier
? defaultReactImportSpecifier.local.name
: undefined;

const namedReactImports = components.getNamedReactImports();
const useStateReactImportSpecifier = namedReactImports
? namedReactImports.find((specifier) => specifier.imported.name === 'useState')
: undefined;

const isSingleGetter = valueVariable && variableNodes.length === 1;
const isUseStateCalledWithSingleArgument = node.arguments.length === 1;
if (isSingleGetter && isUseStateCalledWithSingleArgument) {
const useMemoReactImportSpecifier = namedReactImports
&& namedReactImports.find((specifier) => specifier.imported.name === 'useMemo');

let useMemoCode;
if (useMemoReactImportSpecifier) {
useMemoCode = useMemoReactImportSpecifier.local.name;
} else if (defaultReactImportName) {
useMemoCode = `${defaultReactImportName}.useMemo`;
} else {
useMemoCode = 'useMemo';
}

suggestions.unshift({
desc: 'Replace useState call with useMemo',
fix: (fixer) => [
// Add useMemo import, if necessary
useStateReactImportSpecifier
&& (!useMemoReactImportSpecifier || defaultReactImportName)
&& fixer.insertTextAfter(useStateReactImportSpecifier, ', useMemo'),
// Convert single-value destructure to simple assignment
fixer.replaceTextRange(node.parent.id.range, valueVariableName),
// Convert useState call to useMemo + arrow function + dependency array
fixer.replaceTextRange(
node.range,
`${useMemoCode}(() => ${context.getSourceCode().getText(node.arguments[0])}, [])`
),
].filter(Boolean),
});
}

report(
context,
messages.useStateErrorMessage,
'useStateErrorMessage',
{
node: node.parent.id,
suggest: suggestions,
}
);
}
},
})),
};

0 comments on commit 4f54108

Please sign in to comment.