hook-use-state.js 5.45 KB
/**
 * @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 destructuring and 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 caseCandidateMatch = valueVariableName ? valueVariableName.match(/(^[a-z]+)(.*)/) : undefined;
      const upperCaseCandidatePrefix = caseCandidateMatch ? caseCandidateMatch[1] : undefined;
      const caseCandidateSuffix = caseCandidateMatch ? caseCandidateMatch[2] : undefined;
      const expectedSetterVariableNames = upperCaseCandidatePrefix ? [
        `set${upperCaseCandidatePrefix.charAt(0).toUpperCase()}${upperCaseCandidatePrefix.slice(1)}${caseCandidateSuffix}`,
        `set${upperCaseCandidatePrefix.toUpperCase()}${caseCandidateSuffix}`,
      ] : [];

      const isSymmetricGetterSetterPair = valueVariable
        && setterVariable
        && expectedSetterVariableNames.indexOf(setterVariableName) !== -1
        && variableNodes.length === 2;

      if (!isSymmetricGetterSetterPair) {
        const suggestions = [
          {
            desc: 'Destructure useState call into value + setter pair',
            fix: (fixer) => {
              if (expectedSetterVariableNames.length === 0) {
                return;
              }

              const fix = fixer.replaceTextRange(
                node.parent.id.range,
                `[${valueVariableName}, ${expectedSetterVariableNames[0]}]`
              );

              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,
          }
        );
      }
    },
  })),
};