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.

On this page · 16 sections▾
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-onis set. - Suppression layer — three config keys (
ignore.rules,ignore.files,ignore.overrides) plus inline// react-doctor-disable-next-linecomments. 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— flagsconst [x, setX] = useState(propX). Derived state desynchronizes the momentpropXchanges; 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 isuseMemo.no-cascading-set-state— multiplesetXcalls 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— flagsuseStatevalues that are only ever read inside handlers (not the render path or a derived value). SuggestsuseRef. 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— flagsuseStatecalls whose update logic is complex enough thatuseReducerwould carry the intent better.no-fetch-in-effect— flags client-side fetch insideuseEffectwhen 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-hooksv6 or v7 — adds the React Compiler frontend rules under thereact-hooks-js/*namespace. Only fires when the React Compiler is detected in the project.eslint-plugin-react-you-might-not-need-an-effectv0.10+ — complementary effects rules undereffect/*. 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 --yesWrites .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-onoff, 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:
React Doctor v2 is here
— Aiden Bai (@aidenybai) May 8, 2026
Your agent writes bad React code, this catches it
Works with Next.js, Vite, React Native. Fix your app in minutes
npx react-doctor@latest
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:
Introducing React Doctor Agent Skill
— Aiden Bai (@aidenybai) May 9, 2026
Your agent can now catch bad React code and fix it too
npx react-doctor@latest install
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-hooksv6+. - 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-linedidn’t apply. React Doctor’s--explainflag does this natively.
All sources & links
Primary sources
- millionco/react-doctor on GitHub — repository, README, action.yml, oxlint-config.ts (the authoritative rule list).
- react-doctor on npm — version history, dependencies.
- react.doctor — the demo site and the public leaderboard.
- Aiden Bai’s v1 launch tweet (February 17, 2026) and the v2 launch tweet (May 8, 2026).
Community sources
- Issue #146 — false positives on
rerender-state-only-in-handlersforuseMemoand 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
- React docs: You Might Not Need an Effect — the canon many of the State and Effects rules derive from.
- oxlint documentation — the engine under the hood.
- knip.dev — the dead-code scanner bundled into the default scan.
- Bun catalogs documentation — relevant if you hit version-detection issues in a Bun monorepo.
Internal links on MCP.Directory
- What are Claude Code skills? — for the agent-install side of react-doctor’s pitch.
- Skills vs MCP vs Subagents vs CLI — where react-doctor lands in the four-channel matrix.
- Cross-agent skill portability — the install path writes to 50+ agents; this is the map.
- Claude Code client guide
- Cursor client guide