React Doctor
ActionsAbout
Your agent writes bad React, this catches it.
One command scans your codebase and outputs a 0 to 100 health score with actionable diagnostics.
Works with Next.js, Vite, and React Native.
Run this at your project root:
npx -y react-doctor@latest .You'll get a score (75+ Great, 50 to 74 Needs work, under 50 Critical) and a list of issues across state & effects, performance, architecture, security, accessibility, and dead code. Rules toggle automatically based on your framework and React version.
Main.mp4
Teach your coding agent React best practices so it stops writing the bad code in the first place.
npx -y react-doctor@latest installYou'll be prompted to pick which detected agents to install for. Pass --yes to skip prompts.
Works with Claude Code, Cursor, Codex, OpenCode, and 50+ other agents.
A composite action ships with this repository. Drop it into .github/workflows/react-doctor.yml:
name: React Doctor
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
pull-requests: write # required to post PR comments
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: main
github-token: ${{ secrets.GITHUB_TOKEN }}When github-token is set on pull_request events, findings are posted (and updated) as a PR comment. The action also exposes a score output (0–100) you can use in subsequent steps.
Inputs: directory, verbose, project, diff, github-token, fail-on (error / warning / none), offline, node-version. See action.yml for full descriptions.
Prefer not to add a marketplace action? The bare npx form works too:
- run: npx -y react-doctor@latest --fail-on warningCreate a react-doctor.config.json in your project root:
{
"ignore": {
"rules": ["react/no-danger", "jsx-a11y/no-autofocus"],
"files": ["src/generated/**"],
"overrides": [
{
"files": ["components/modules/diff/**"],
"rules": ["react-doctor/no-array-index-as-key", "react-doctor/no-render-in-render"]
},
{
"files": ["components/search/HighlightedSnippet.tsx"],
"rules": ["react/no-danger"]
}
]
}
}Three nested keys, three layers of granularity — pick the narrowest one that fits:
ignore.rulessilences a rule across the whole codebase.ignore.filessilences every rule on the matched files (use sparingly — it loses coverage for unrelated rules).ignore.overridessilences only the listed rules on the matched files, leaving every other rule active. This is what you want when a single file (or glob) legitimately needs an exemption from one or two rules but should still be scanned for everything else.
You can also use the "reactDoctor" key in package.json. CLI flags always override config values.
React Doctor respects .gitignore, .eslintignore, .oxlintignore, .prettierignore, and linguist-vendored / linguist-generated annotations in .gitattributes. Inline // eslint-disable* and // oxlint-disable* comments are honored too.
If you have a JSON oxlint or eslint config (.oxlintrc.json or .eslintrc.json), its rules get merged into the scan automatically and count toward the score. Set adoptExistingLintConfig: false to opt out.
When the following ESLint plugins are installed in the scanned project (or hoisted in your monorepo), React Doctor folds their rules into the same scan. Both are listed as optional peer dependencies — install only what you want.
| Plugin | Adds | Namespace |
|---|---|---|
eslint-plugin-react-hooks (v6 or v7) |
The React Compiler frontend's correctness rules — fired when a React Compiler is detected in the project. | react-hooks-js/* |
eslint-plugin-react-you-might-not-need-an-effect (v0.10+) |
Complementary effects-as-anti-pattern rules (no-derived-state, no-chain-state-updates, no-event-handler, no-pass-data-to-parent, …) that run alongside React Doctor's native State & Effects rules. |
effect/* |
// react-doctor-disable-next-line react-doctor/no-cascading-set-state
useEffect(() => {
setA(value);
setB(value);
}, [value]);When two rules fire on the same line, you have two equivalent options. Comma-separate the rule ids on a single comment:
// react-doctor-disable-next-line react-doctor/rerender-state-only-in-handlers, react-doctor/no-derived-useState
const [localSearch, setLocalSearch] = useState(searchQuery);Or stack one comment per rule directly above the diagnostic. Stacked comments are honored as long as nothing but other react-doctor-disable-next-line comments sits between them and the target line:
// react-doctor-disable-next-line react-doctor/rerender-state-only-in-handlers
// react-doctor-disable-next-line react-doctor/no-derived-useState
const [localSearch, setLocalSearch] = useState(searchQuery);A code line between stacked comments breaks the chain: only the comment immediately above the diagnostic (and any contiguous react-doctor-disable-next-line comments stacked on top of it) is honored. If a comment looks adjacent but the rule still fires, run react-doctor --explain <file:line> — it reports whether a nearby suppression was found, what rules it covers, and why it didn't apply.
Block comments work inside JSX:
{/* react-doctor-disable-next-line react/no-danger */}
<div dangerouslySetInnerHTML={{ __html }} />For multi-line JSX, putting the comment immediately above the opening tag covers the entire attribute list (matching ESLint convention).
The same rule set ships as both an oxlint plugin and an ESLint plugin, so you can wire it into whichever lint engine your project already runs.
oxlint in .oxlintrc.json:
ESLint flat config:
import reactDoctor from "react-doctor/eslint-plugin";
export default [
reactDoctor.configs.recommended,
reactDoctor.configs.next,
reactDoctor.configs["react-native"],
reactDoctor.configs["tanstack-start"],
reactDoctor.configs["tanstack-query"],
];The full rule list lives in oxlint-config.ts.
Usage: react-doctor [directory] [options]
Options:
-v, --version display the version number
--no-lint skip linting
--no-dead-code skip dead code detection
--verbose show every rule and per-file details (default shows top 3 rules)
--score output only the score
--json output a single structured JSON report
-y, --yes skip prompts, scan all workspace projects
--full skip prompts, always run a full scan
--project <name> select workspace project (comma-separated for multiple)
--diff [base] scan only files changed vs base branch
--staged scan only staged files (for pre-commit hooks)
--offline skip telemetry
--fail-on <level> exit with error on diagnostics: error, warning, none
--annotations output diagnostics as GitHub Actions annotations
--explain <file:line> diagnose why a rule fired or why a suppression didn't apply
--why <file:line> alias for --explain
-h, --help display help
When a suppression isn't working, --explain <file:line> (or its alias --why <file:line>) reports what the scanner sees at that location, including why a nearby react-doctor-disable-next-line didn't apply. The diagnosis distinguishes the common failure modes — adjacent comment for a different rule (use the comma form), a code line between the comment and the diagnostic (the chain is broken), or no nearby suppression at all. The same hint surfaces inline with --verbose for every flagged site, and in --json output as diagnostic.suppressionHint, so a single scan doubles as a suppression audit without a separate flag.
--json produces a parsable object on stdout with all human-readable output suppressed. Errors still produce a JSON object with ok: false, so stdout is always a valid document.
| Key | Type | Default |
|---|---|---|
ignore.rules |
string[] |
[] |
ignore.files |
string[] |
[] |
ignore.overrides |
{ files, rules? }[] |
[] |
lint |
boolean |
true |
deadCode |
boolean |
true |
verbose |
boolean |
false |
diff |
boolean | string |
|
failOn |
"error" | "warning" | "none" |
"none" |
customRulesOnly |
boolean |
false |
share |
boolean |
true |
textComponents |
string[] |
[] |
rawTextWrapperComponents |
string[] |
[] |
respectInlineDisables |
boolean |
true |
adoptExistingLintConfig |
boolean |
true |
textComponents is the broad escape hatch for rn-no-raw-text — list components that themselves behave like React Native's <Text> (custom Typography, NativeTabs.Trigger.Label, etc.) and the rule will treat them as text containers regardless of what their children look like.
rawTextWrapperComponents is the narrower option for components that are not text elements but safely route string-only children through an internal <Text> (e.g. heroui-native's Button, which stringifies its children and renders them through a ButtonLabel). Listed wrappers suppress rn-no-raw-text only when their children are entirely stringifiable. A wrapper with mixed children — e.g. <Button>Save<Icon /></Button> — still reports because the wrapper can't safely route raw text alongside a sibling JSX element.
import { diagnose, toJsonReport, summarizeDiagnostics } from "react-doctor/api";
const result = await diagnose("./path/to/your/react-project");
console.log(result.score); // { score: 82, label: "Great" } or null
console.log(result.diagnostics); // Diagnostic[]
console.log(result.project); // detected framework, React version, etc.diagnose accepts a second argument: { lint?: boolean, deadCode?: boolean }.
const report = toJsonReport(result, { version: "1.0.0" });
const counts = summarizeDiagnostics(result.diagnostics);react-doctor/api re-exports JsonReport, JsonReportSummary, JsonReportProjectEntry, JsonReportMode, plus the lower-level buildJsonReport and buildJsonReportError builders. See packages/react-doctor/src/api.ts for the full types.
Top React codebases scanned by React Doctor, ranked by score. Updated automatically from millionco/react-doctor-benchmarks.
| # | Repo | Score |
|---|---|---|
| 1 | executor | 96 |
| 2 | nodejs.org | 86 |
| 3 | tldraw | 70 |
| 4 | t3code | 68 |
| 5 | better-auth | 64 |
| 6 | excalidraw | 63 |
| 7 | mastra | 63 |
| 8 | payload | 60 |
| 9 | typebot | 57 |
| 10 | plane | 56 |
See the full leaderboard.
Want to try it out? Check out the demo.
Looking to contribute back? Clone the repo, install, build, and submit a PR.
git clone https://github.com/millionco/react-doctor
cd react-doctor
pnpm install
pnpm build
node packages/react-doctor/bin/react-doctor.js /path/to/your/react-projectFind a bug? Head to the issue tracker.
React Doctor is MIT-licensed open-source software.
React Doctor is not certified by GitHub. It is provided by a third-party and is governed by separate terms of service, privacy policy, and support documentation.
{ "jsPlugins": [{ "name": "react-doctor", "specifier": "react-doctor/oxlint-plugin" }], "rules": { "react-doctor/no-fetch-in-effect": "warn", "react-doctor/no-derived-state-effect": "warn", }, }