EmpireUI
Get Pro
← Blog7 min read#eslint#react#linting

ESLint Configuration for React: Rules That Catch Real Bugs

ESLint catches the React bugs your eyes miss — stale closures, missing deps, unsafe key usage. Here's a config that actually finds real problems in 2026.

Terminal window showing ESLint output with React warnings and error messages highlighted in red

Why Most ESLint Setups Miss the Point

Honestly, most teams slap ESLint on their React project, accept whatever create-react-app or Vite scaffolds for them, and call it done. Then six months later someone pushes a stale closure bug that slips through code review and breaks production at 2am. The default config didn't catch it. It never will.

ESLint isn't just a style enforcer. That's what Prettier is for. ESLint, when configured properly, catches logic bugs before they run — missing effect dependencies, unsafe array index keys, hooks called conditionally, exhausted try/catch blocks. The difference between a janky default config and a tuned one is the difference between a linter that's decorative and one that earns its place in CI.

This article walks through a React ESLint setup that's actually useful. We'll cover the specific rules that catch real problems, explain why each one matters, and include the full config you can drop into your project today. Using ESLint v9.x with the new flat config format, eslint-plugin-react@7.37.x, and eslint-plugin-react-hooks@5.x.

Setting Up the Flat Config Format (ESLint v9+)

ESLint v9 shipped the flat config as the default. If you're still on a .eslintrc.json or .eslintrc.js file, you're on legacy config. It still works — for now — but the ecosystem is moving fast. New plugins are dropping legacy support. Worth migrating.

Here's a baseline eslint.config.js for a React project. This is the starting point we'll build on throughout this article.

import js from '@eslint/js';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';

export default [
  js.configs.recommended,
  {
    files: ['**/*.{js,jsx,ts,tsx}'],
    plugins: {
      react: reactPlugin,
      'react-hooks': reactHooks,
    },
    languageOptions: {
      globals: {
        ...globals.browser,
        ...globals.es2022,
      },
      parserOptions: {
        ecmaFeatures: { jsx: true },
      },
    },
    settings: {
      react: { version: 'detect' },
    },
    rules: {
      ...reactPlugin.configs.recommended.rules,
      ...reactHooks.configs.recommended.rules,
    },
  },
];

The version: 'detect' in settings tells the plugin to read your installed React version rather than hardcoding 18 or 19. It's a small thing, but it means you won't get spurious warnings when you upgrade React.

The react-hooks/exhaustive-deps Rule Is the Big One

If you only enable one rule beyond the defaults, make it react-hooks/exhaustive-deps. It's set to warn by the plugin's recommended config. Change it to error. Stale closures are the number-one source of subtle, hard-to-reproduce React bugs, and this rule catches them at save time.

The rule flags useEffect, useCallback, useMemo, and useLayoutEffect calls where the dependency array doesn't match the values actually used inside the callback. It sounds mechanical, and it mostly is — but the bugs it prevents are genuinely painful to debug in production.

What's the most common ESLint suppression comment you see in React codebases? It's // eslint-disable-next-line react-hooks/exhaustive-deps. Nine times out of ten, that comment is hiding a real bug rather than a legitimate exception. When you see that comment in a PR, treat it like a red flag and read the surrounding code carefully.

Rules That Prevent Runtime Crashes

Beyond hooks, there's a handful of rules that prevent straight-up crashes. react/jsx-no-target-blank catches <a target="_blank"> without rel="noopener noreferrer" — a security issue, but also causes broken behavior in certain browser configs. Enable it as error.

The react/no-array-index-key rule deserves its own mention. Using array indices as React keys (key={index}) causes wrong renders when lists reorder or items are inserted. It doesn't throw — it silently renders the wrong content. The default is off. Turn it to warn at minimum, error if your team has the discipline to fix it consistently.

// Add these to your rules object:
{
  // Crashes and security
  'react/jsx-no-target-blank': 'error',
  'react/no-array-index-key': 'warn',
  'react/no-danger': 'warn',
  'react/no-direct-mutation-state': 'error',

  // Hooks correctness
  'react-hooks/rules-of-hooks': 'error',
  'react-hooks/exhaustive-deps': 'error',

  // Stale patterns
  'react/no-deprecated': 'error',
  'react/no-string-refs': 'error',
}

react/no-deprecated catches usage of APIs React has marked for removal — things like componentWillMount and findDOMNode. If you're maintaining an older codebase, this rule surfaces a real migration list.

TypeScript Projects Need @typescript-eslint Too

If you're on TypeScript — and you should be for anything beyond a weekend project — the base ESLint rules are not enough. @typescript-eslint/eslint-plugin@8.x with @typescript-eslint/parser@8.x adds a second layer of type-aware rules that catch TypeScript-specific problems ESLint can't see.

The rule @typescript-eslint/no-floating-promises is the one that pays for itself fastest. It flags Promise-returning async functions whose results aren't awaited or caught. In React, this shows up constantly in event handlers and useEffect cleanup. The rule requires type-checking enabled (parserOptions.project: true), which slows linting down noticeably on large projects — but it's worth it in CI.

Two other rules worth enabling: @typescript-eslint/no-explicit-any as warn (not error — you'll need escape hatches during migrations) and @typescript-eslint/consistent-type-imports to enforce import type syntax. The latter pairs nicely if you're using tools that need to strip type imports at build time. Pairs well with Vite's fast HMR in dev.

Configuring Rules Per File Pattern

Flat config makes per-pattern overrides clean and explicit. Test files don't need the same strictness as production code. You'll often want to allow any in test utilities, disable certain hooks rules in mock components, and loosen import restrictions inside *.test.tsx files.

Here's how to add a test-specific override block that relaxes rules for files under src/**/*.test.{ts,tsx} and src/__tests__/**.

// Add this as a second object in your config array:
{
  files: ['**/*.test.{js,jsx,ts,tsx}', '**/__tests__/**'],
  rules: {
    '@typescript-eslint/no-explicit-any': 'off',
    'react/display-name': 'off',
    'react-hooks/rules-of-hooks': 'off', // for test wrappers
  },
},

This pattern also works for Storybook files, where you're often writing components that intentionally break certain conventions for demonstration purposes. You might be building something like a theme toggle in React where the story file needs to render the same hook in multiple configurations — ESLint's hooks rules would false-positive without an override.

Running ESLint in CI Without Slowing Down Everything

ESLint in CI is non-negotiable. But naive setups — running it across the entire project on every push — can add 30-60 seconds to a pipeline that developers are already impatient about. A few tweaks make it faster.

First, use --cache and --cache-location .eslintcache. ESLint only re-lints changed files when the cache is warm. In a GitHub Actions workflow, cache the .eslintcache file between runs keyed on your lockfile hash. Second, for type-aware rules with @typescript-eslint, run them only in a dedicated lint job that doesn't block the main test pipeline — or run them nightly rather than on every PR.

If you're using lint-staged with Husky for pre-commit hooks, scope ESLint to staged files only: eslint --fix --max-warnings=0 --cache. The --max-warnings=0 flag makes warnings fail the commit, which forces the team to resolve them rather than letting them accumulate. Similar to how you'd want zero tolerance for broken visual styles — if you're generating UI with tools like box-shadow patterns or gradient utilities, you want the same zero-tolerance mindset for lint debt.

A Complete Config to Copy

Here's the full eslint.config.js combining everything from this article. It's opinionated but not extreme. You can tune the severity levels based on your team's situation — start with warn if you're retrofitting an existing codebase, then promote to error once the violations are cleared.

import js from '@eslint/js';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import globals from 'globals';

export default [
  js.configs.recommended,
  {
    files: ['**/*.{ts,tsx}'],
    plugins: {
      react: reactPlugin,
      'react-hooks': reactHooks,
      '@typescript-eslint': tseslint,
    },
    languageOptions: {
      parser: tsParser,
      parserOptions: {
        ecmaFeatures: { jsx: true },
        project: true,
      },
      globals: {
        ...globals.browser,
        ...globals.es2022,
      },
    },
    settings: {
      react: { version: 'detect' },
    },
    rules: {
      ...reactPlugin.configs.recommended.rules,
      ...reactHooks.configs.recommended.rules,
      'react/react-in-jsx-scope': 'off', // React 17+ JSX transform
      'react/prop-types': 'off',         // TypeScript handles this
      'react/jsx-no-target-blank': 'error',
      'react/no-array-index-key': 'warn',
      'react/no-danger': 'warn',
      'react/no-direct-mutation-state': 'error',
      'react/no-deprecated': 'error',
      'react/no-string-refs': 'error',
      'react-hooks/rules-of-hooks': 'error',
      'react-hooks/exhaustive-deps': 'error',
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/consistent-type-imports': 'error',
      '@typescript-eslint/no-unused-vars': [
        'error',
        { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
      ],
    },
  },
  {
    files: ['**/*.test.{ts,tsx}', '**/__tests__/**'],
    rules: {
      '@typescript-eslint/no-explicit-any': 'off',
      'react/display-name': 'off',
    },
  },
];

One note on react/react-in-jsx-scope: turn it off. With the modern JSX transform (React 17+, and every project using Vite or the current Next.js), you don't need to import React at the top of every file. Leaving this rule on generates hundreds of false positives and trains your team to ignore ESLint output — which defeats the whole point. For more on pairing a clean component setup with tools like CSS glassmorphism utilities, the same principle applies: turn off the noise so the signal stands out.

FAQ

Should I use eslint-plugin-react-hooks separately or is it bundled?

It's a separate package — npm install -D eslint-plugin-react-hooks. As of v5.x it's not bundled with eslint-plugin-react. Install both and register them as separate plugins in your flat config.

Why does `react-hooks/exhaustive-deps` keep flagging my custom hook inside useEffect?

If the custom hook returns a new function reference on every render, ESLint correctly flags it as a missing dep. The fix is either memoizing the return value with useCallback inside the custom hook, or using the useRef pattern to hold a stable reference. Don't suppress with a disable comment — fix the actual instability.

Does ESLint flat config work with Next.js 15?

Yes, Next.js 15 ships with eslint-config-next updated for flat config. Use compat.extends('next/core-web-vitals') from @eslint/compat to wrap it into your flat config array. The Next.js docs have a migration example.

How do I stop ESLint from linting generated files or build output?

Add an ignores array at the top of your flat config export: { ignores: ['dist/**', '.next/**', 'coverage/**', '*.min.js'] }. This replaces the old .eslintignore file. Note it must be a top-level object in the config array, not nested inside another config object.

Can I run type-aware TypeScript ESLint rules without slowing down local dev?

Yes — split your config. Use a tsconfig.eslint.json that includes only source files (not tests or config files) and point parserOptions.project at it. For local dev, you can run the cheaper non-type-aware rules; reserve type-aware rules like no-floating-promises for CI only by toggling an env var in your config.

Is react/prop-types still useful in 2026?

No, not if you're on TypeScript. Disable it with 'react/prop-types': 'off'. TypeScript's type checking covers everything prop-types does, and having both active generates redundant errors that train developers to ignore lint output.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Stylelint Configuration: Catch CSS Errors Before They Reach ProdPrettier Configuration: Format on Save for React + TypeScriptFrontend Framework Wars 2026: The Definitive Comparison GuideReact Compiler in 2026: Auto-Memoization and What It Changes