Two correct files, one infinite loop
Dashboard owns the state. Filters trims it and hands it back.
Neither file is wrong on its own.
Dashboard (1 hooks) src/Dashboard.tsx ✓ Filters (1 hooks) src/Filters.tsx warn cross-component-infinite-loop [hook:0] (line 4:2) this effect calls `onChange`, a state setter of parent `Dashboard` (its deps do not provably gate it, so the effect can re-run every render). Parent re-renders → child re-renders → effect fires again: infinite loop ⚠ 1 warning(s) across 2 file(s).
The spread builds a new object every run. Object.is fails, the parent re-renders,
the child gets a new value, the effect fires again.
ESLint passes both files: the deps array is complete, no hook is conditional. reactant
follows setQuery across the import. The finding names both ends of the cycle:
the effect in Filters, the setter it borrows from Dashboard.
What it catches
Seventeen diagnostics. Most have no ESLint counterpart. reactant rules lists them
all, reactant explain <rule> gives an example and a fix for one.
Loops that never settle
infinite-loop- an effect sets state that re-triggers the effect, and the value never settles
cross-component-infinite-loop- a child effect sets parent state, the parent re-renders the child, the effect fires again
setter-in-render- setState runs during the render body
cross-setter-in-render- the same, reached through a prop
State that should not be state
derived-state- an effect only mirrors another state, so compute it during render
unnecessary-rerender- a mount-only effect immediately overwrites the initial state
redundant-set-state- setState is called with the value the state already holds
Values frozen in time
stale-closure- a long-lived callback keeps reading a value frozen at registration time
frozen-initial-state- useState is seeded from a prop that later changes, so the state sticks at the first value
Identity and mutation
state-mutation- a state or prop object is mutated in place, so the reference never changes and React skips the re-render
unstable-context-value- a provider hands consumers a new object every render
lazy-init- a useState initializer calls a function on every render
Lifecycle and environment
missing-cleanup- an effect starts something long-lived and returns no teardown
server-component-hook- a hook runs in a Next.js Server Component, where hooks do not exist
Also covered by ESLint
reactant follows the value rather than the syntax, so these also fire through helpers and cross-file custom hooks.
missing-deps- exhaustive-deps, carried through indirection
always-unstable-deps- partly covered by exhaustive-deps
conditional-hook- rules-of-hooks, carried past the lexical case
Where reactant fits
reactant does not replace ESLint or React Compiler. Each one runs at a different moment. ESLint checks the file you are typing in, the compiler rewrites your code at build time, and reactant checks the whole project in CI. Installing reactant changes nothing in your ESLint config and nothing in your build.
eslint-plugin-react-hooks
In your editor, as you type a few ms per file
It checks the lexical rules of hooks, such as the deps array and the conditional call. It runs on every keystroke because it never leaves the file it is reading.
It matches patterns in the AST. Move the hook call into a helper, or into a custom hook in another file, and the pattern no longer matches.
React Compiler
In your build part of the build
It memoizes for you. Where it compiles, it removes the re-renders that unstable references cause.
It optimizes and does not report bugs. When it cannot compile a component it skips it without saying so.
reactant
In CI, or before a commit 1.8s for 528 files
It tracks what each value can hold across renders and across files. It follows setters through the call graph and inlines custom hooks.
It builds a graph of the whole project before it reports anything, so it cannot run on every keystroke. One run takes seconds and covers every file at once.
What each one catches
| Bug class | ESLint | reactant |
|---|---|---|
| Unused vars, imports, style | catches | not its job |
| Conditional hook call | catches | catches |
| Missing effect dep | same file only | catches |
| Unstable dep or context value | same file only | catches |
| Infinite render loop | syntax only | catches |
| Derived state in an effect | syntax only | catches |
| Stale closure | syntax only | catches |
| Cross-component cycle | syntax only | catches |
In CI
The repository is also a GitHub Action. Every finding becomes an annotation on the file and line that produced it.
name: reactant
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rboudrouss/reactant-analyzer@v0.6.0
with:
path: . # the project root, so tsconfig aliases load
fail-on: error # warnings annotate the PR without failing it Inputs and outputs are documented in action.yml.
For AI-generated code
LLMs write exactly these bugs, and they write them behind enough abstraction that AST
patterns never fire. --format json gives a machine-readable report where every
finding carries a witness chain, the typed steps that led to it, so an agent can fix the cause
instead of the line.
reactant also ships as a Claude Code plugin:
/plugin marketplace add rboudrouss/reactant-analyzer
/plugin install reactant@reactant-analyzer reactant-triage runs the analyzer and sorts each finding into true positive,
false positive, or not worth fixing. reactant-rules writes custom rule packs.
Team rules
Team conventions ship as rule packs, written against facts the engine already resolved (which hook a value came from, what a setter writes, what a selector returns) rather than against source patterns, so they survive refactoring. Written in JSON or JavaScript.
{
"packs": ["@team/react-rules", "./rules/pack.json"],
"rules": {
"team/oversized-effect": { "severity": "warning" },
"team/banned-hook": "off"
}
} Documentation
- docs/usage.md: every flag, the JSON schema, project detection, exit codes
- docs/custom-rules.md: writing rule packs
- docs/limitations.md: what it misses and what it may report wrongly
- docs/plugins.md: the Rust API for custom discoverers and resolvers
- docs/adr/: how the analysis works and why each decision was made