🧩 Creating a Custom ESLint Plugin

An ESLint plugin is an extension for ESLint that adds additional rules and configuration options. Plugins let you customize your ESLint configuration to enforce rules that are not included in the core ESLint package. Plugins can also provide additional environments, custom processors, and configurations.


Background

I'm using useRequest from ahooks to fetch data, but tracking the dependent parameters of refreshDeps can be error-prone and inconvenient. One workaround is to wrap the request function in useCallback and add it as a dependency, but this is not ideal for developer experience. To improve this, I created a custom ESLint plugin that provides dependency checking and suggestions for useRequest, similar to eslint-plugin-react-hooks for React hooks.


Why Write a Custom ESLint Plugin?

  • Enforce project-specific best practices not covered by core ESLint or existing plugins.
  • Catch bugs early by statically analyzing code for common mistakes.
  • Improve code consistency and maintainability across teams.
  • Automate tedious code review feedback with actionable suggestions and autofixes.

Anatomy of an ESLint Plugin

An ESLint plugin is a Node.js module that exports one or more rules, processors, environments, or configs. Each rule is an object with a meta property (describing the rule) and a create function (which returns visitor functions for AST nodes).


Adding a Rule

A basic rule starts with minimal code as follows:

module.exports = {
  meta: {
    type: 'suggestion', // 'problem', 'suggestion', or 'layout'
    docs: {
      description: 'verifies the list of dependencies for useRequest',
      recommended: true,
      url: null, // URL to the documentation page for this rule
    },
    fixable: null, // 'code' or 'whitespace' if the rule supports --fix
    hasSuggestions: true,
    schema: [], // Add a schema if the rule has options
  },
  create: function (context) {
    return {
      Program: function (node) {
        // Add the rule logic here
      },
    }
  },
}

You can check out the Custom Rules documentation for more information.


Implementing the Rule

To avoid reinventing the wheel, we can leverage the logic from eslint-plugin-react-hooks's exhaustive-deps rule, adapting it for useRequest. The rule inspects the dependencies array (refreshDeps) and provides suggestions or autofixes if dependencies are missing or extraneous.

Key points:

  • Detects calls to useRequest.
  • Checks the refreshDeps property in the options object.
  • Ignores checks if manual: true is set.
  • Reports missing or extra dependencies, and can autofix if enabled.
FULL SOURCE CODE!!!
const reactHooksPlugin = require('eslint-plugin-react-hooks')
const reactHooksCreate = reactHooksPlugin.rules['exhaustive-deps'].create
const depKey = 'refreshDeps'
 
module.exports = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'verifies the list of dependencies for useRequest',
      recommended: true,
    },
    fixable: 'code',
    hasSuggestions: true,
    schema: [
      {
        type: 'object',
        additionalProperties: false,
        autoFix: false,
        properties: {
          autoFix: {
            type: 'boolean',
          },
        },
      },
    ],
  },
  create(context) {
    return {
      CallExpression(node) {
        if (
          node.callee.type === 'Identifier' &&
          node.callee.name === 'useRequest'
        ) {
          // Logic to extract and check refreshDeps...
          const arg1 = node.arguments[1] // useRequest options
          let manual = false
          let refreshDeps
          let refreshDepsRange
          if (arg1) {
            if (arg1.type === 'ObjectExpression') {
              for (const prop of arg1.properties) {
                if (prop.key.name === 'manual' && prop.value.value) {
                  manual = true
                } else if (prop.key.name === depKey) {
                  refreshDeps = prop.value.elements
                  refreshDepsRange = prop.value.range
                }
              }
            } else {
              return
            }
          }
          if (manual) return
          node.arguments[1] = {
            type: 'ArrayExpression',
            elements: refreshDeps ?? [],
            loc: node.loc,
            parent: node.parent,
            range: refreshDepsRange ?? node.range,
          }
          const report = (problem) => {
            problem.message = problem.message.replace(
              /Either include (it|them) or remove the dependency array/,
              `Please add $1 to '${depKey}'`,
            )
            problem.message = problem.message.replace(
              /Either exclude (it|them) or remove the dependency array/,
              `Please remove $1 from '${depKey}'`,
            )
            const suggest = problem.suggest[0]
            const fix = (fixer) => {
              if (refreshDepsRange) {
                return suggest.fix(fixer)
              }
              let depsStr
              suggest.fix({
                replaceText: function (_, deps) {
                  depsStr = deps
                },
              })
              const replacementText = `${depKey}: ${depsStr}`
              if (arg1) {
                const arg1Text = context.sourceCode.getText(arg1)
                return fixer.replaceText(
                  arg1,
                  `${arg1Text.replace(
                    /,?\s*\}\s*,?$/,
                    arg1.properties.length ? ',' : '',
                  )} ${replacementText} }${arg1Text.endsWith(',') ? ',' : ''}`,
                )
              }
              const nodeText = context.sourceCode.getText(node)
              return fixer.replaceText(
                node,
                `${nodeText.replace(
                  /,?\s*\)\s*,?$/,
                  ',',
                )} { ${replacementText} })${nodeText.endsWith(',') ? ',' : ''}`,
              )
            }
            problem.fix = context.options[0]?.autoFix && fix
            problem.suggest = [
              {
                desc: suggest.desc,
                fix,
              },
            ]
            context.report(problem)
          }
          const reactHooksCallExpression = reactHooksCreate({
            options: [{ additionalHooks: `(useRequest)` }],
            report,
            getScope: context.getScope,
            getSource: context.getSource,
            getSourceCode: context.getSourceCode,
            sourceCode: context.sourceCode,
          }).CallExpression
          reactHooksCallExpression(node)
        }
      },
    }
  },
}

This rule uses the AST to find useRequest calls, extracts the refreshDeps array, and delegates dependency analysis to the React Hooks plugin logic, customizing the error messages and autofix for the ahooks use case.


How to Use Your Custom Plugin

  1. Publish or link your plugin (e.g., eslint-plugin-myplugin) in your project.
  2. Add it to your ESLint config:
.eslintrc
{
  "plugins": ["myplugin"],
  "rules": {
    "myplugin/use-request-refresh-deps": "warn"
  }
}
  1. Run ESLint as usual. Violations will be reported with suggestions or autofixes.

Testing Your Rule

  • Use ESLint RuleTester to write unit tests for your rule logic.
  • Try your rule on real codebases to ensure it catches real-world issues and doesn't produce false positives.
  • Consider publishing your plugin to npm for others to use.

Tips for Writing ESLint Plugins

  • Use AST Explorer to experiment with AST node structures.
  • Follow the ESLint plugin guidelines.
  • Provide clear documentation and examples for your rules.
  • Support autofix and suggestions where possible for better developer experience.

References & Further Reading


By creating custom ESLint plugins, you can enforce project-specific conventions, catch bugs early, and improve the overall quality and consistency of your codebase.