React Japan light logo
article hero
Steven Sacks
Steven Sacks

ESLint - Foundation

The following are my recommendations for ESLint, Prettier, Stylelint, and editorconfig.

ESLint v8.57.0

First and foremost, even though ESLint v9 is available, most plugins are incompatible with it. For this reason, I strongly suggest sticking with version 8.57.0 until more plugins are made compatible with v9.

Plugins

These are the plugins with rulesets (with some customization) that will form the foundation of your ESLint setup.

Here is my recommended configuration in the .eslintrc.cjs file. You can choose to modify the setting of the rules as you see fit, but I believe these rules should all be set to something. This setup supports js and ts files (as well as cjs, mjs etc.). I have added comments as notes for this article, which you can remove.

const shared = {
  extends: [
    'eslint:recommended',
    'plugin:eslint-comments/recommended',
    'plugin:react/recommended',
    'plugin:react/jsx-runtime',
    'plugin:react-hooks/recommended',
    'plugin:jsx-a11y/recommended',
    'plugin:sonarjs/recommended-legacy',
    'plugin:tailwindcss/recommended',
    'plugin:unicorn/recommended',
    'prettier',
  ],
  plugins: [
    'react',
    'sonarjs',
    'unicorn',
    'tailwindcss', // if you use tailwind
    'you-dont-need-lodash-underscore', // if you use lodash
  ],
  rules: [
    'consistent-return': 'off',
    curly: ['error', 'all'],
    
    'eslint-comments/disable-enable-pair': 'off',
    'eslint-comments/no-unused-disable': 'error',
    
    'import/extensions': 'off',
    'import/no-anonymous-default-export': [
      'error',
      {
        allowArray: true,
        allowLiteral: true,
        allowObject: true,
      },
    ],
    'import/order': 'off', // handled by another plugin
    'import/prefer-default-export': 'off',
    
    'jsx-a11y/anchor-is-valid': [
      'error',
      {
        components: ['Link'], // if you use Remix, Next, etc.
        specialLink: ['to'],
      },
    ],
    
    'max-params': ['error'], // default is 3
    'padding-line-between-statements': [
      'error',
      {
        blankLine: 'always',
        next: ['block-like', 'export', 'return'],
        prev: '*',
      },
    ],
    quotes: [
      'error',
      'single',
      {
        allowTemplateLiterals: false,
        avoidEscape: true,
      },
    ],
    
    'react/boolean-prop-naming': [
      'error',
      {
        propTypeNames: ['bool', 'mutuallyExclusiveTrueProps'],
        rule: '^((is|has|can|show|hide|no)[A-Z]([A-Za-z0-9]?)+|(show|hide|disabled|required))',
      },
    ],
    'react/function-component-definition': 'off',
    'react/jsx-boolean-value': ['error', 'always'],
    'react/jsx-filename-extension': ['error', {extensions: ['.tsx']}],
    'react/jsx-newline': ['error', {prevent: true}],
    'react/jsx-props-no-spreading': 'off',
    'react/prop-types': 'off', // use TypeScript for props
    'react/require-default-props': 'off',
    
    'spaced-comment': 'off', // good in theory, annoying in practice
    
    // handled by prettier-plugin-tailwindcss
    'tailwindcss/classnames-order': 'off',
    
    'unicorn/consistent-destructuring': 'error',
 
    // the following should be turned off for React compatability
    // and a few are rules I believe are problematic
    'unicorn/new-for-builtins': 'off',
    'unicorn/no-array-callback-reference': 'off',
    'unicorn/no-array-for-each': 'off',
    'unicorn/no-array-reduce': 'off',
    'unicorn/no-null': 'off',
    'unicorn/no-useless-undefined': 'off',
    'unicorn/prefer-export-from': 'off',
    'unicorn/prefer-set-has': 'off',
    'unicorn/prefer-switch': 'off',
    'unicorn/prefer-ternary': 'off',
    
    'unicorn/prevent-abbreviations': [
      'error',
      {
        // these are my preferences
        ignore: [
          'acc',
          'ctx',
          'e2e',
          'env',
          'obj',
          'prev',
          'req',
          'res',
          /args/i,
          /fn/i,
          /params/i,
          /props/i,
          /ref/i,
          /utils/i,
        ],
      },
    ],
    
    'unicorn/text-encoding-identifier-case': 'off',
  ],
};
 
module.exports = {
  root: true,
  env: {
    browser: true,
    commonjs: true,
    es2024: true,
    node: true,
  },
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: 'latest',
    sourceType: 'module',
  },
  ignorePatterns: ['node_modules', 'build'],
  extends: ['airbnb', ...shared.extends],
  plugins: shared.plugins,
  rules: shared.rules,
  overrides: [
    {
      files: ['**/*.ts?(x)'],
      extends: [
        'airbnb',
        'airbnb-typescript',
        'plugin:@typescript-eslint/recommended',
        'plugin:import/recommended',
        'plugin:import/typescript',
        ...shared.extends,
      ],
      parser: '@typescript-eslint/parser',
      parserOptions: {
        project: './tsconfig.json',
      },
      plugins: [
        ...shared.plugins,
        '@typescript-eslint',
        'import',
      ],
      settings: {
        'import/internal-regex': '^~/', // see below why tilde
        'import/resolver': {
          node: {
            extensions: ['.ts', '.tsx'],
          },
          typescript: {
            alwaysTryTypes: true,
          },
        },
        react: {
          pragma: 'React',
          version: 'detect',
        },
        tailwindcss: {
          callees: ['twJoin', 'twMerge'], // tailwind-merge
        },
      },
      rules: {
        ...shared.rules,
        'no-undef': 'off', // TypeScript handles this
        'no-unused-vars': 'off', // TypeScript handles this
        '@typescript-eslint/array-type': ['error', {default: 'generic'}],
        '@typescript-eslint/ban-ts-comment': 'off',
        '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
        '@typescript-eslint/method-signature-style': 'error',
        '@typescript-eslint/no-throw-literal': 'off',
        '@typescript-eslint/no-non-null-assertion': 'off',
        '@typescript-eslint/no-unnecessary-boolean-literal-compare': ['error'],
        '@typescript-eslint/no-unused-vars': 'error',
      },
    },
    {
      files: ['*.tsx', '**/hooks/*.ts?(x)'],
      rules: {
        // too easy to violate in components and hooks
        'sonarjs/cognitive-complexity': 'off',
      },
    },
    {
      files: ['src/?(components|pages|services|utils)/**/*.ts?(x)'],
      rules: {
        'max-lines': ['error', 200],
      },
    },
    {
      files: ['./*.ts'], // only project root files
      rules: {
        'global-require': 'off',
        'import/no-extraneous-dependencies': 'off',
        'import/no-unresolved': 'error',
        'import/prefer-default-export': 'off',
        'no-void': 'off',
        'unicorn/prefer-module': 'off',
        'unicorn/prevent-abbreviations': 'off',
      },
    },
    {
      files: ['**/*.d.ts'],
      rules: {
        '@typescript-eslint/consistent-type-definitions': 'off',
        '@typescript-eslint/method-signature-style': 'error',
        '@typescript-eslint/no-unused-vars': 'off',
      },
    },
  ],
};

Details

You can lookup any of the rules yourself and decide if you want to change the setting. Here are my justifications for some of them.

  • max-params - In functional programming, you should ideally only have 1 param per function. In practice, this can be a bit too restrictive, so having a limit of 3 params is sensible before converting to a single object with named params. Too many params requires developers to memorize the order and also makes multiple optional params a pain. It does not matter if you can look up the signature or TypeScript helps you. In practice, this limit is better. React components are a great example of this in practice because props is single object param.
  • padding-line-between-statements - This rule ensures consistent code styling and readability. It is a “prettier”-type rule because if you just accept that it does what it does, it ensures that everyone’s code is formatted the same, and you get used to it quickly.
  • quotes - This ensures optimal quotes for all 3 types (single, double, backtick).
  • react/jsx-boolean-value - Being explicit is better, and it makes props look consistent. Even for people who don’t prefer this, since it fixes on save, they can write it without being explicit, and when they save, it makes it explicit, and thus consistent for everyone.
  • import/internal-regex - Remix uses ~ tilde, and I believe this is superior because so many installed packages use @. By using ~, there is a distinct difference between importing your code vs code from node_modules. This needs to be configured inside your tsconfig.json, as well (paths).
  • @typescript-eslint/array-type - Pick the setting you prefer. There are good arguments for both array and generic. This enforces one or the other automatically and consistency is our goal. It automatically fixes to the preferred one on save, so developers can write whichever is more comfortable for them.
  • max-lines - If your components are getting too large, it’s usually a sign that you should break them up into smaller ones for performance, readability, and separation of concerns. There are going to be exceptions here and there. In those cases, the developer can disable this rule in the file, and it can be discussed during a code review. In my experience, nearly all of my component files are less than 200 lines, but you can increase this to 250, or use the default of 300, if you prefer.
  • @typescript-eslint/consistent-type-definitions - You should use type for functional programming and immutability. interface is for OOP and mutability. This article has a good explanation why. No matter what, you want to consistently use one or the other. The rule is disabled for .d.ts files because third party libraries may require interface.

.prettierrc

These are my Prettier settings.

{
  "bracketSpacing": false,
  "experimentalTernaries": true,
  "plugins": ["prettier-plugin-tailwindcss"], // if you use tailwind
  "singleQuote": true,
  "tailwindFunctions": ["twJoin", "twMerge"], // if you use tailwind
  "trailingComma": "es5"
}

bracket-spacing

Some people like bracket-spacing. I think it makes code wider than it needs to be, and I don’t find that it improves readability. If you like it, stick with the default setting.

experimentalTernaries

You can read about them here. These will eventually become the default, so I enable them to get used to them sooner. I agree with the Prettier team that they are better.

plugins

If you use Tailwind, you should use the Tailwind Prettier Plugin.

single-quote

It is easier to type single quotes (does not require typing two keys at once), and whether it is more common to use apostrophes or double-quotes inside of strings, it does not outweigh how frequently you type single quotes while coding. Single quotes have been the standard for a very long time, and, in my experience, everyone sets this to true.

tailwindFunctions

If you use Tailwind, you should use Tailwind Merge, instead of clsx or classnames.

trailingComma

Trailing commas are great! But, I don’t like them in function parameters. I think they look messy before the closing parentheses and arrow const foo = (a, b, c,) =>. So, I set this to “es5” instead of “all”.

.stylelintrc.json

Here are my suggested settings for Stylelint.

{
  "extends": [
    "stylelint-config-standard",
    "stylelint-config-idiomatic-order",
    "stylelint-config-tailwindcss" // if you're using tailwind
  ],
  "ignoreFiles": ["build/**/*.css", "public/build/**/*.css"],
  "overrides": [
    {
      "files": ["**/*.module.css"],
      "rules": {
        // enforces camelCase class names for css modules
        "selector-class-pattern": "^[a-z][a-zA-Z0-9]+$"
      }
    }
  ],
  "plugins": ["stylelint-order"],
  "rules": {
    "no-descending-specificity": null,
    "selector-pseudo-class-no-unknown": [
      true,
      {
        "ignorePseudoClasses": ["global", "local"]
      }
    ]
  }
}

.editorconfig

IDEs will automatically follow your rules by adding this file to the root of your project. These are my suggested settings.

# editorconfig.org
 
root = true
 
# We recommend to keep these unchanged
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
 
[*.md]
trim_trailing_whitespace = false

Next article: ESLint - Sorting

Main article: Take your workflow to the next level with ESLint fix on save