From c5b65a9f7dff6be89c99c842d03586f7c84737d0 Mon Sep 17 00:00:00 2001 From: Duncan Beevers Date: Wed, 9 Dec 2020 09:52:43 -0800 Subject: [PATCH] [New] Symmetric useState hook variable names Ensure two symmetrically-named variables are destructured from useState hook calls --- README.md | 1 + docs/rules/hook-use-state.md | 46 +++ index.js | 1 + lib/rules/hook-use-state.js | 152 +++++++++ tests/lib/rules/hook-use-state.js | 548 ++++++++++++++++++++++++++++++ 5 files changed, 748 insertions(+) create mode 100644 docs/rules/hook-use-state.md create mode 100644 lib/rules/hook-use-state.js create mode 100644 tests/lib/rules/hook-use-state.js diff --git a/README.md b/README.md index b10df85fef..83d875d267 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/rules/hook-use-state.md b/docs/rules/hook-use-state.md new file mode 100644 index 0000000000..0fb071762f --- /dev/null +++ b/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(); +} +``` diff --git a/index.js b/index.js index 6b8fbcb8f9..367688457d 100644 --- a/index.js +++ b/index.js @@ -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'), diff --git a/lib/rules/hook-use-state.js b/lib/rules/hook-use-state.js new file mode 100644 index 0000000000..6bfb604ae8 --- /dev/null +++ b/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, + } + ); + } + }, + })), +}; diff --git a/tests/lib/rules/hook-use-state.js b/tests/lib/rules/hook-use-state.js new file mode 100644 index 0000000000..e8b379dacf --- /dev/null +++ b/tests/lib/rules/hook-use-state.js @@ -0,0 +1,548 @@ +/** + * @fileoverview Ensure symmetric naming of setState hook value and setter variables + * @author Duncan Beevers + */ + +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester; +const rule = require('../../../lib/rules/hook-use-state'); +const parsers = require('../../helpers/parsers'); + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + }, +}); + +const tests = { + valid: parsers.all([ + { + code: ` + import { useState } from 'react' + function useColor() { + const [color, setColor] = useState() + return [color, setColor] + } + `, + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [color, setColor] = useState('#ffffff') + return [color, setColor] + } + `, + }, + { + code: ` + import React from 'react'; + function useColor() { + const [color, setColor] = React.useState() + return [color, setColor] + } + `, + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [color1, setColor1] = useState() + return [color1, setColor1] + } + `, + }, + { + code: 'useState()', + }, + { + code: 'const result = useState()', + }, + { + code: 'const [color, setFlavor] = useState()', + }, + { + code: ` + import React from 'react' + import useState from 'someOtherUseState' + const [color, setFlavor] = useState() + `, + }, + { + code: ` + import { useRef } from 'react' + const result = useState() + `, + }, + { + code: ` + import { useState as useStateAlternativeName } from 'react' + function useColor() { + const [color, setColor] = useStateAlternativeName() + return [color, setColor] + } + `, + }, + { + code: 'const result = React.useState()', + }, + { + code: ` + import { useState } from 'react' + function useColor() { + return useState() + } + `, + }, + { + code: ` + import { useState } from 'react' + function useColor() { + function useState() { // shadows React's useState + return null + } + + const result = useState() + } + `, + }, + { + code: ` + import React from 'react' + function useColor() { + const React = { + useState: () => { + return null + } + } + + const result = React.useState() + } + `, + }, + { + code: ` + import { useState } from 'react'; + function useColor() { + const [color, setColor] = useState() + return [color, setColor] + } + `, + features: ['ts', 'no-babel-old'], + }, + { + code: ` + import { useState } from 'react'; + function useColor() { + const [color, setColor] = useState('#ffffff') + return [color, setColor] + } + `, + features: ['ts'], + }, + { + code: ` + import { useState } from 'react'; + const result = useState(); + `, + }, + ]), + invalid: parsers.all([ + { + code: ` + import { useState } from 'react'; + function useColor() { + const result = useState() + return result + } + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import { useState as useStateAlternativeName } from 'react' + function useColor() { + const result = useStateAlternativeName() + return result + } + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import React from 'react' + function useColor() { + const result = React.useState() + return result + } + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import ReactAlternative from 'react' + function useColor() { + const result = ReactAlternative.useState() + return result + } + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const result = useState() + return result + } + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [, , extra1] = useState() + } + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [, setColor] = useState() + } + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const { color } = useState() + } + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [] = useState() + } + `, + errors: [{ + message: 'useState call is not destructured into value + setter pair', + }], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [, , , ,] = useState() + } + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [color] = useState() + } + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + output: ` + import { useState } from 'react' + function useColor() { + const [color, setColor] = useState() + } + `, + }, + ], + }, + ], + }, + { + code: ` + import { useState } from 'react' + function useColor(initialColor) { + const [color] = useState(initialColor) + } + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + desc: 'Replace useState call with useMemo', + output: ` + import { useState, useMemo } from 'react' + function useColor(initialColor) { + const color = useMemo(() => initialColor, []) + } + `, + }, + { + desc: 'Destructure useState call into value + setter pair', + output: ` + import { useState } from 'react' + function useColor(initialColor) { + const [color, setColor] = useState(initialColor) + } + `, + }, + ], + }, + ], + }, + { + code: ` + import { useState, useMemo as useMemoAlternative } from 'react' + function useColor(initialColor) { + const [color] = useState(initialColor) + } + `, + errors: [{ + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + desc: 'Replace useState call with useMemo', + output: ` + import { useState, useMemo as useMemoAlternative } from 'react' + function useColor(initialColor) { + const color = useMemoAlternative(() => initialColor, []) + } + `, + }, + { + desc: 'Destructure useState call into value + setter pair', + output: ` + import { useState, useMemo as useMemoAlternative } from 'react' + function useColor(initialColor) { + const [color, setColor] = useState(initialColor) + } + `, + }, + ], + }], + }, + { + code: ` + import React from 'react' + function useColor(initialColor) { + const [color] = React.useState(initialColor) + } + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + desc: 'Replace useState call with useMemo', + output: ` + import React from 'react' + function useColor(initialColor) { + const color = React.useMemo(() => initialColor, []) + } + `, + }, + { + desc: 'Destructure useState call into value + setter pair', + output: ` + import React from 'react' + function useColor(initialColor) { + const [color, setColor] = React.useState(initialColor) + } + `, + }, + ], + }, + ], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [color, , extra1] = useState() + return [color] + } + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + output: ` + import { useState } from 'react' + function useColor() { + const [color, setColor] = useState() + return [color] + } + `, + }, + ], + }, + ], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [color, setColor, extra1, extra2, extra3] = useState() + return [color, setColor] + } + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + output: ` + import { useState } from 'react' + function useColor() { + const [color, setColor] = useState() + return [color, setColor] + } + `, + }, + ], + }, + ], + }, + { + code: ` + import { useState } from 'react' + const [, makeColor] = useState() + `, + errors: [{ message: 'useState call is not destructured into value + setter pair' }], + }, + { + code: ` + import { useState } from 'react' + const [color, setFlavor, extraneous] = useState(); + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + output: ` + import { useState } from 'react' + const [color, setColor] = useState(); + `, + }, + ], + }, + ], + }, + { + code: ` + import { useState } from 'react' + const [color, setFlavor] = useState() + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + output: ` + import { useState } from 'react' + const [color, setColor] = useState() + `, + }, + ], + }, + ], + }, + { + code: ` + import { useState } from 'react' + const [color, setFlavor] = useState() + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + output: ` + import { useState } from 'react' + const [color, setColor] = useState() + `, + }, + ], + }, + ], + features: ['ts', 'no-babel-old'], + }, + { + code: ` + import { useState } from 'react' + function useColor() { + const [color, setFlavor] = useState('#ffffff') + return [color, setFlavor] + } + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + output: ` + import { useState } from 'react' + function useColor() { + const [color, setColor] = useState('#ffffff') + return [color, setFlavor] + } + `, + }, + ], + }, + ], + features: ['ts', 'no-babel-old'], + }, + { + code: ` + import React from 'react' + function useColor() { + const [color, setFlavor] = React.useState('#ffffff') + return [color, setFlavor] + } + `, + errors: [ + { + message: 'useState call is not destructured into value + setter pair', + suggestions: [ + { + output: ` + import React from 'react' + function useColor() { + const [color, setColor] = React.useState('#ffffff') + return [color, setFlavor] + } + `, + }, + ], + }, + ], + features: ['ts', 'no-babel-old'], + }, + ]), +}; + +ruleTester.run('hook-set-state-names', rule, tests);