Updated May 2026Intermediate18 min read

React Doctor: Lint the Bad React Your AI Agent Wrote

Your AI agent writes React that compiles, renders, and quietly leaks bugs. Derived state. useEffect chains that should have been a useMemo. Array-index keys. Prop drilling instead of context. React Doctor — from the Million.js team — is one npx command that catches the lot, scores your codebase 0-to-100, and (the part that’s actually new) installs itself into Claude Code and Cursor so the agent stops writing those patterns in the first place. Below: the full rule catalog, the gotchas I hit, and when to skip it.

Abstract editorial diagram of react-doctor: a React-style atom orbited by six diagnostic glyphs — correctness, performance, security, architecture, deep scan, and a 0-to-100 health score — under the gaze of a soft cyan magnifier lens.
On this page · 16 sections
  1. One-sentence definition
  2. Why it exists
  3. Mental model
  4. Smallest run
  5. Deep dive: rule catalog
  6. What we got wrong
  7. Workflow examples
  8. Common mistakes
  9. Performance and overhead
  10. Who this is for
  11. Community signal
  12. The verdict
  13. The bigger picture
  14. FAQ
  15. Glossary
  16. All sources

One-sentence definition

React Doctor is an oxlint-based scanner with ~100 React-aware rules and a bundled dead-code pass, packaged so you can run it as a one-shot npx command, ship it in CI, or install it as an agent skill that teaches Claude Code and Cursor what bad React looks like before it gets written.

The pitch reduces further: a linter that meets the AI-coding era where it actually hurts — useEffect soup, derived state, prop drilling instead of context, accessibility ignored, framework-specific footguns. Everything else here is a deeper read of one piece of that sentence.

Why react-doctor exists

The launch post on February 17, 2026 (1.07 million views, 11k bookmarks) framed the problem in three lines: “Scan your React codebase for anti-patterns: unnecessary useEffects, fix accessibility issues, prop drilling instead of context / composition. Run as a CLI or agent skill. Repeat until passing.” The v2 launch on May 8 changed the tagline to the one that stuck: your agent writes bad React code, this catches it.

That shift matters. ESLint and TypeScript catch syntax errors and type mismatches. They don’t catch the things an agent gets wrong even when the code “works.” The Million team’s observation is that agents — Claude, Cursor, Codex, Copilot — confidently produce React code that compiles, runs in the browser, and is structurally bad: useState mirroring props, useEffect chains setting one state from another, list keys pinned to array indexes, Next.js <img> tags where next/image belongs, Server Components doing client fetches.

The pre-doctor world was not impossible — careful PR review catches most of it. It was tedious, and it didn’t scale to the volume of code an agent produces in a single afternoon. React Doctor compresses that review into one command and pushes the rules upstream into the agent itself via npx -y react-doctor@latest install, which writes a skill or rules file into Claude Code, Cursor, Codex, OpenCode, and 50-plus other clients.

“Linter” is the wrong category if you stop there

Calling react-doctor “a linter” gets you to the first 60% — diagnostics, suppressions, CI integration. It misses the move that’s actually new: the same rule set ships as an agent skill, an oxlint plugin, an ESLint plugin, and a GitHub Action. Picking one of those four channels is the real configuration decision, not picking which rules to enable.

Mental model: the pieces

Five named pieces. Once you know these, everything in the README is a deeper read of one of them.

              ┌─────────────────────────────────────┐
              │   Your React / RN / Next codebase   │
              └────────────────┬────────────────────┘
                               │
                  ┌────────────┴────────────┐
                  │                         │
        ┌─────────▼────────┐     ┌──────────▼─────────┐
        │   Scanner core   │     │   Dead-code pass   │
        │   (oxlint + ~100 │     │   (bundled knip)   │
        │   doctor rules)  │     │                    │
        └─────────┬────────┘     └──────────┬─────────┘
                  │                         │
                  └────────────┬────────────┘
                               ▼
                   ┌───────────────────────┐
                   │  Diagnostics + score  │
                   │   (0–100, by file)    │
                   └───────────┬───────────┘
                               │
            ┌──────────────────┼──────────────────┐
            ▼                  ▼                  ▼
        Terminal           PR comment       Agent skill
        (CLI run)        (GitHub Action)    (Claude/Cursor)
  • Scanner core — an oxlint-based engine that ships ~100 React-aware rules out of the box, grouped into state-and-effects, performance, architecture, security, accessibility, plus framework packs (Next.js, React Native, TanStack Start). Rules auto-toggle by detected framework and React version.
  • Dead-code pass — a bundled knip run that surfaces unused files, exports, and dependencies. Skippable via --no-dead-code; folded into the score by default.
  • Score — a 0-to-100 number with three bands: 75+ Great, 50–74 Needs work, under 50 Critical. Computed from diagnostic count and severity, normalized by codebase size. Bands carry color in the terminal output and gate CI when --fail-on is set.
  • Suppression layer — three config keys (ignore.rules, ignore.files, ignore.overrides) plus inline // react-doctor-disable-next-line comments. ESLint, oxlint, and prettier ignore files are honored without config.
  • Distribution — five channels: the bare CLI, the marketplace GitHub Action (millionco/react-doctor@main), the oxlint plugin (react-doctor/oxlint-plugin), the ESLint plugin (react-doctor/eslint-plugin), and the agent-skill installer (react-doctor@latest install). Same rule set in every channel.

Smallest end-to-end run

The shortest path is one command at the root of a React, Vite, Next.js, or React Native project:

$ npx -y react-doctor@latest .

Detecting React. Found React ^19.2.0.
Detecting framework. Found Next.js ^15.4.2.
Scanning 412 files...

  components/ai/chat-input.tsx:42:3
    react-doctor/no-derived-useState
    `localQuery` mirrors prop `query` via useState — derived state
    will desync. Compute it inline, or lift state up.

  components/dashboard/metric-card.tsx:18:5
    react-doctor/no-array-index-as-key
    `key={i}` on a list with stable items causes reconciliation
    bugs on reorder. Use the item's stable id.

  app/(marketing)/page.tsx:7:1
    react-doctor/nextjs-no-img-element
    Use `next/image` instead of `<img>` for automatic
    optimization, lazy loading, and CLS protection.

  ... 14 more issues across 6 files

Score: 78 (Great)

Three flags unlock the rest of the workflow: --score prints just the number (good for shell pipelines), --json emits a structured report (parseable by downstream tooling), and --diff main limits the scan to files changed against a branch (the CI pattern). For pre-commit hooks, --staged scans only staged files.

The second half of the workflow is the agent install:

$ npx -y react-doctor@latest install

Detected coding agents:
  ◉ Claude Code (~/.claude)
  ◉ Cursor (./.cursor)
  ○ Codex
  ○ OpenCode

Install for: [enter to confirm, space to toggle]
> ◉ Claude Code
  ◉ Cursor

Writing .claude/skills/react-doctor/SKILL.md...
Writing .cursor/rules/react-doctor.mdc...
Done. Restart your agent to pick up the new rules.

That second command is the lever the v2 launch was about. The installed skill teaches the agent the same rules the scanner enforces — so the agent stops generating array-index keys, stops mirroring props into state, stops reaching for useEffect when a derived value would do.

Deep dive: the rule catalog

State and effects (the heart of the package)

The State and Effects pack is where react-doctor earns its name. The rules target the patterns that compile fine, render fine, and quietly produce bugs — almost every one of them attacks an anti-pattern from the React team’s “You Might Not Need an Effect” doc. Highlights:

  • no-derived-useState — flags const [x, setX] = useState(propX). Derived state desynchronizes the moment propX changes; compute it inline or lift state up.
  • no-derived-state-effect — flags effects whose only job is to recompute one state from another, e.g. useEffect(() => setFiltered(items.filter(...)), [items]). The fix is useMemo.
  • no-cascading-set-state — multiple setX calls inside one effect that triggers the next render that triggers the next effect. The footgun behind most “why is this component rendering five times?” bugs.
  • no-effect-event-handler — flags effects whose body is a chain of state setters that should have lived inside the event handler that triggered the change.
  • rerender-state-only-in-handlers — flags useState values that are only ever read inside handlers (not the render path or a derived value). Suggests useRef. This one has a known false-positive shape, called out below.
  • no-render-in-render — flags React component invocations that happen inside another component’s render body (as opposed to being returned as JSX).
  • prefer-useReducer — flags useState calls whose update logic is complex enough that useReducer would carry the intent better.
  • no-fetch-in-effect — flags client-side fetch inside useEffect when a Server Component, route loader, or React Query would do the job with less code and less waterfall.
  • effect-needs-cleanup — flags effects that subscribe (event listener, interval, websocket) without returning a cleanup function.

Opinion: this pack alone is worth the install. ESLint’s exhaustive-deps tells you the dependency array is wrong; these rules tell you the effect shouldn’t exist at all.

Performance and JS micro-rules

The js-* namespace bundles ~13 rules that flag expensive patterns inside hot paths: hoisting RegExp and Intl instances (js-hoist-regexp, js-hoist-intl), using a Set or Map for repeated lookups (js-set-map-lookups), combining adjacent iterations (js-combine-iterations), early-return (js-early-exit), batching DOM and CSS reads (js-batch-dom-css), caching the result of expensive property access (js-cache-property-access),flatMap instead of filter().map()(js-flatmap-filter). Several derive from Vladimir Klepov’s “Render Performance Manifesto” canon.

Opinion: useful, but the wins are small per site. Don’t fail CI on these. Read them as code-review hints.

Architecture and design

design-no-redundant-padding-axes and design-no-redundant-size-axes flag Tailwind patterns like w-4 h-4 that compress to size-4 (Tailwind 3.4+ only — react-doctor detects the Tailwind major.minor and gates the rule). design-no-default-tailwind-palette flags raw palette names (bg-blue-500) when the project has custom design tokens. design-no-vague-button-label catches <button>Click here</button> — accessibility plus UX in one rule.

Next.js pack

Sixteen rules at last count, including: nextjs-no-img-element (use next/image), nextjs-async-client-component (catches "use client" + async function signature — fatal in 15+), nextjs-no-use-search-params-without-suspense, nextjs-no-client-fetch-for-server-data (Server Component bypassed), nextjs-missing-metadata, nextjs-no-client-side-redirect, nextjs-no-redirect-in-try-catch (Next.js redirect() throws — wrapping it swallows the redirect), nextjs-no-side-effect-in-get-handler, nextjs-no-head-import (pages-router next/head in the App Router).

React Native pack

Twenty-four RN-specific rules. The most-cited one in the issue tracker is rn-no-raw-text — raw strings outside a <Text> crash at runtime — followed by rn-prefer-pressable (TouchableOpacity is dead), rn-prefer-expo-image, rn-no-dimensions-get (use useWindowDimensions), rn-prefer-reanimated, and a swarm of FlatList performance rules (rn-no-inline-flatlist-renderitem, rn-list-callback-per-row, rn-no-inline-object-in-list-item).

TanStack Start pack

Fourteen rules tuned to the new TanStack-Start router and server-function model: tanstack-start-no-direct-fetch-in-loader, tanstack-start-server-fn-validate-input, tanstack-start-no-secrets-in-loader, tanstack-start-loader-parallel-fetch. This pack shipped within weeks of TanStack Start 1.0 — a strong signal about where the team is paying attention.

Companion plugins

Two optional peer dependencies fold into the same scan when installed:

  • eslint-plugin-react-hooks v6 or v7 — adds the React Compiler frontend rules under the react-hooks-js/* namespace. Only fires when the React Compiler is detected in the project.
  • eslint-plugin-react-you-might-not-need-an-effect v0.10+ — complementary effects rules under effect/*. Nick van Dyke’s plugin is effectively the “you might not need an effect” doc in rule form.

What we got wrong

Three assumptions about react-doctor that the issue tracker punished:

1. We assumed the score was directly comparable across repos. It isn’t. The leaderboard at react.doctor/leaderboard is seductive — tldraw at 70, Excalidraw at 63, Plane at 56 — but the score is normalized by codebase size, not by domain or by framework split. A 90-line Vite playground will trivially outscore a 200k-line Next.js app even if both are equally careful. Use the score as a delta against itself over time, not as a ranking signal.

2. We pinned to the agent install and stopped scanning manually. The agent install is excellent prophylactic. It is not a replacement for a CI run. Agents drift on long sessions, inherited code skips the prompt path entirely, and human edits aren’t guarded. The two are additive: skill install shapes new code, the CI scan catches the rest.

3. We treated ignore.files as the obvious suppression knob. It’s the wrong one almost always. ignore.files silences every rule on the matched files — you lose coverage for unrelated rules and the file goes dark. The right knob is ignore.overrides, which silences specific rules on specific files while keeping every other rule active. This shipped in response to issue #160 — read the issue before you reach for ignore.files.

Real-world workflow examples

Pre-commit (staged-only scan)

The cheapest place to run react-doctor is the pre-commit hook — it only scans the files you’re about to commit, so it takes under a second on most repos. The husky + lint-staged combo wires it in three lines:

// package.json
{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": "react-doctor . --staged --fail-on warning"
  }
}

CI on every PR (diff-only)

The composite action posts findings as a PR comment and exposes a score output:

# .github/workflows/react-doctor.yml
name: React Doctor

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write

jobs:
  react-doctor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0  # required for --diff
      - uses: millionco/react-doctor@main
        with:
          diff: ${{ github.base_ref }}
          fail-on: warning
          github-token: ${{ secrets.GITHUB_TOKEN }}

Agent skill: teach Claude Code the rules

The one-time install:

npx -y react-doctor@latest install --yes

Writes .claude/skills/react-doctor/SKILL.md (and equivalents for Cursor, Codex, OpenCode). Subsequent prompts that touch React code activate the skill, and the agent applies the same rules the scanner would catch. Pair with periodic CLI runs to verify the agent isn’t silently dropping rules.

Programmatic API (custom dashboards)

import { diagnose, summarizeDiagnostics } from "react-doctor/api";

const result = await diagnose("./apps/web");
console.log(result.score);          // { score: 82, label: "Great" }
console.log(result.project);        // { framework: "next", reactVersion: "19.2.0", ... }
console.log(summarizeDiagnostics(result.diagnostics));
//   {
//     "react-doctor/no-derived-useState": 3,
//     "react-doctor/no-array-index-as-key": 7,
//     ...
//   }

Useful for an internal dashboard, a multi-repo health board, or a custom CI that wants to gate by rule category rather than the aggregate score.

Common mistakes

Suppression looks adjacent but rule still fires

Root cause: the rule reports a line on a later attribute of a multi-line JSX element, and the // react-doctor-disable-next-line sits above the opening tag. Issue #158 documents the shape. Fix: run react-doctor --explain <file:line> for a diagnosis, then either move the comment immediately above the reported attribute, or hoist the expression into a const.

Bun grouped catalogs not resolved

Root cause: 0.1.4 resolved Bun’s general catalog but not workspaces.catalogs.<group>. Fixed in 0.1.5+. If you see “No React dependency found in /path/to/apps/web/package.json” on a Bun monorepo, pin to the latest version.

CI job fails on “Needs work” score even with fail-on: error

Root cause: the composite action’s internal score step exits non-zero on scores 50–74 regardless of the user’s fail-on setting (issue #190, fixed in subsequent commits). If your matrix job dies silently with no diagnostics, pin the action to a known-good SHA or fall back to the bare npx form.

rerender-state-only-in-handlers false positives

Root cause: the rule checks whether state is read in the return JSX. State that flows through useMemo, context value, or a derived const is invisible to the check — the rule suggests useRef, which would break rendering. Issue #146 has examples. Suppress per-site until the heuristic tightens.

rn-no-raw-text false positive on wrapper components

Root cause: components that safely route string children through an internal <Text> (heroui-native’s Button, lingui’s <Trans>) get flagged. Fix: list them under rawTextWrapperComponents in config, which only suppresses when children are stringifiable.

Performance and overhead

React Doctor ships on the oxlint engine — Boshen’s Rust-based linter — which is the headline performance number. On a representative ~500-file Next.js app the full scan (including the knip dead-code pass) finishes in 4–8 seconds on a 2024 M-series laptop; the lint-only scan (skip dead code, pass --no-dead-code) drops to 1–2 seconds. The --diff form scoped to a 20-file pull request finishes in under a second.

For comparison, an equivalent ESLint flat-config scan on the same codebase with comparable rule coverage typically runs 10–30× slower. The performance gap is the whole reason the CLI exists as its own binary — same rule set, but you would not run it on every commit if it cost 30 seconds.

Three caveats. First, the dead-code pass dominates wall time on large monorepos with deep workspace graphs — if your CI run feels slow, --no-dead-code is the lever. Second, npx -y react-doctor@latest fetches the package each run unless cached; pin to a version in CI to avoid the resolution overhead. Third, the binary is large (multi-MB), because it bundles oxlint, knip, and the rule data.

Who this is for, who it isn’t

Pick it if…

  • You ship React/Next.js/RN and let an agent write day-to-day code
  • You want one command for state, perf, a11y, security, and dead code
  • Your ESLint config is patchwork and you want a consolidated baseline
  • You can spare 10 minutes to install the skill into your agent

Skip it if…

  • Your ESLint config is already tuned and you have no complaints
  • You ship Vue, Svelte, Solid, Qwik, or anything not React (it’s React-only)
  • You need rule explanations stable enough to gate strict compliance audits — the rule set is still iterating
  • You can’t accept any false positives in CI (turn --fail-on off, treat as advisory)

Community signal

Aiden Bai’s launch tweet for v1 in February 2026 cleared a million views and 11k bookmarks within days. The v2 launch in May framed the agent angle directly:

The follow-up about the agent-skill install hit a slightly quieter ~108k views but more bookmarks per view — the audience that’s actually using coding agents in production is smaller than the audience scrolling launch posts, and they paid more attention to the install than to the scan:

The issue tracker is the second signal. Within roughly three months it accumulated 200-plus issues, the substantive bulk of them detailed bug reports with reproductions, proposed fixes, and offers to send PRs. Issue #161 — a feature request for a native suppression-audit CLI — opens with a user saying they wrote a 130-line classifier during their own 740-site cleanup and would love it built-in. That is what an active product looks like.

The contrarian voice in the tracker is the false-positive strand: #146 on rerender-state-only-in-handlers mis-suggesting useRef, #180 on rn-no-raw-text not understanding <Trans>, #183 on the same rule misfiring on heroui-native components. The Million team has been closing these within days, but the pattern is clear: a young rule catalog is going to get the heuristic wrong on idiomatic patterns it hasn’t seen. Treat warnings as advisory in the first weeks; promote to errors once your noise rate is low.

The Verdict

Our take

Install it. The State and Effects pack alone catches enough agent-generated bugs in one afternoon to justify the keystroke cost forever. Pair the CLI with the agent-skill install — the skill shapes new code, the CLI catches the rest, and the GitHub Action makes both visible on PRs. Use it if you ship React and an LLM writes more than ~30% of your code. Skip it if your team is small, your ESLint is tight, and your tolerance for rule churn is zero — circle back in six months when the rule catalog has stabilized.

The bigger picture

React Doctor sits in the same category as Million.js — Aiden Bai’s other project, the virtual-DOM compiler that compiles React components into faster runtime representations. Same team, same diagnosis: React is the most widely shipped UI framework on the planet, AI is writing more of it every week, and the gap between “the code compiles” and “the code is good” is widening. Million attacks the runtime side. React Doctor attacks the source side.

A broader pattern: agent-aware tooling is starting to look like a real category. Anthropic’s Claude Code skills are one channel — markdown files that teach the agent a workflow. React Doctor’s install path is another — a tool that knows its own rules well enough to write them into whatever client surface the agent reads from. Expect more tools to ship this shape: a CLI for humans plus an install subcommand for agents, with the same rule set behind both.

The honest critique: react-doctor will inevitably collide with the React Compiler’s built-in correctness rules as that ships more broadly. Some of what react-doctor catches today (cascading setState, derived state effects) will become things the compiler refuses to compile. The team has hedged this by folding eslint-plugin-react-hooks v6/v7 into the same scan — explicitly the React Compiler frontend — so the overlap becomes additive rather than redundant.

Frequently asked questions

What does react-doctor actually scan for?

Six categories: state and effects (unnecessary useEffect, derived state, cascading setState), performance, architecture, security, accessibility, and dead code. Roughly 100 built-in rules plus framework packs for Next.js, React Native, and TanStack Start. Rules auto-toggle by detected framework and React version.

Is react-doctor an ESLint plugin or its own thing?

Both. The CLI runs an oxlint-based engine bundled with knip for dead code. The same rule set also ships as an ESLint plugin (`react-doctor/eslint-plugin`) and an oxlint plugin (`react-doctor/oxlint-plugin`) so you can wire it into whichever lint pipeline you already run.

Does it work in a monorepo?

Yes, with caveats. pnpm catalogs, npm and yarn workspaces, and Bun's general catalog are resolved. Bun grouped catalogs (`workspaces.catalogs.<group>`) were broken in 0.1.4 and fixed shortly after — pin to 0.1.5 or later if you use them. The bundled knip step also reads workspace-local knip.json correctly only in recent versions.

Can I ignore a rule for a single file?

Three layers. `ignore.rules` silences a rule globally. `ignore.files` silences every rule on matched files (lossy). `ignore.overrides` silences specific rules on matched files and is what you almost always want. Inline `// react-doctor-disable-next-line <rule>` works too.

What does the 0-100 score actually mean?

75 plus is Great, 50 to 74 is Needs work, below 50 is Critical. The score is computed from the count and severity of diagnostics, normalized by codebase size. Treat it as a directional signal, not a Lighthouse-style absolute — a large mature codebase landing at 70 is normal.

Does it replace eslint-plugin-react-hooks?

It complements it. If you have eslint-plugin-react-hooks v6 or v7 installed (the React Compiler frontend), react-doctor folds those rules into the same scan under the `react-hooks-js/*` namespace. The native State and Effects rules are additive — they target anti-patterns the React team rules don't cover.

Is it free? Any telemetry?

MIT-licensed, free, fully open source. It does send anonymized telemetry by default — pass `--offline` (or set `share: false` in config) to disable. The leaderboard at react.doctor/leaderboard is opt-in via a separate repo.

Glossary

oxlint
Boshen’s Rust-based JS/TS linter; the engine react-doctor ships on. ~10–30× faster than ESLint on comparable rule sets.
knip
A dead-code scanner (unused files, exports, dependencies). Bundled into react-doctor’s default scan; skip with --no-dead-code.
Derived state
State whose value is computed from another piece of state or props. Usually a bug — compute it inline.
Cascading setState
A setState call that triggers a render that triggers an effect that calls setState. The footgun react-doctor flags most often.
React Compiler
The React team’s opt-in compiler that auto-memoizes components and hooks. Ships with its own correctness rules via eslint-plugin-react-hooks v6+.
Agent skill
A markdown file that teaches a coding agent (Claude Code, Cursor) a specific workflow or rule set. Read on prompt activation, not at startup.
pnpm catalog
A workspace feature for declaring shared dependency versions in one place. React Doctor resolves these (and Bun’s equivalent) to detect the React major.
Composite action
A GitHub Action assembled from other actions/shell steps. React Doctor’s ships at millionco/react-doctor@main.
Streamable HTTP
The current MCP transport for remote servers. Mentioned here because the agent-skill install writes MCP-compatible files for some clients.
Suppression audit
Walking each flagged site and classifying why a nearby disable-next-line didn’t apply. React Doctor’s --explain flag does this natively.

All sources & links

Primary sources

Community sources

  • Issue #146 — false positives on rerender-state-only-in-handlers for useMemo and context paths.
  • Issue #158 — multi-line JSX attribute suppressions.
  • Issue #160 — the proposal that became ignore.overrides.
  • Issue #161 — the suppression-audit feature that became --explain.
  • Issue #190 — composite action exit-code bug on “Needs work” scores.

Documentation referenced

Internal links on MCP.Directory

Keep reading