hook-use-state.js 7.2 KB

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