yannickcr/eslint-plugin-react

View on GitHub
lib/rules/hook-use-state.js

Summary

Maintainability
D
2 days
Test Coverage
/**
 * @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');
const getMessageData = require('../util/message');

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

function isNodeDestructuring(node) {
  return node && (node.type === 'ArrayPattern' || node.type === 'ObjectPattern');
}

const messages = {
  useStateErrorMessage: 'useState call is not destructured into value + setter pair',
  useStateErrorMessageOrAddOption: 'useState call is not destructured into value + setter pair (you can allow destructuring by enabling "allowDestructuredState" option)',
  suggestPair: 'Destructure useState call into value + setter pair',
  suggestMemo: 'Replace useState call with useMemo',
};

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: 'object',
      properties: {
        allowDestructuredState: {
          default: false,
          type: 'boolean',
        },
      },
      additionalProperties: false,
    }],
    type: 'suggestion',
    hasSuggestions: true,
  },

  create: Components.detect((context, components, util) => {
    const configuration = context.options[0] || {};
    const allowDestructuredState = configuration.allowDestructuredState || false;

    return {
      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,
              suggest: false,
            }
          );
          return;
        }

        const variableNodes = node.parent.id.elements;
        const valueVariable = variableNodes[0];
        const setterVariable = variableNodes[1];
        const isOnlyValueDestructuring = isNodeDestructuring(valueVariable) && !isNodeDestructuring(setterVariable);

        if (allowDestructuredState && isOnlyValueDestructuring) {
          return;
        }

        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 = [
            Object.assign(
              getMessageData('suggestPair', messages.suggestPair),
              {
                fix(fixer) {
                  if (expectedSetterVariableNames.length > 0) {
                    return fixer.replaceTextRange(
                      node.parent.id.range,
                      `[${valueVariableName}, ${expectedSetterVariableNames[0]}]`
                    );
                  }
                },
              }
            ),
          ];

          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(Object.assign(
              getMessageData('suggestMemo', messages.suggestMemo),
              {
                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),
              }
            ));
          }

          if (isOnlyValueDestructuring) {
            report(
              context,
              messages.useStateErrorMessageOrAddOption,
              'useStateErrorMessageOrAddOption',
              {
                node: node.parent.id,
                suggest: false,
              }
            );
            return;
          }

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