reactant, a static checker for React hooks
Finds the infinite render loops, stale values and useless re-renders in your React project, across files.
reactant reads your React project as one program. It follows a state setter from the component that owns it to the effect in another file that calls it, and reports the loop on the component that suffers it.
npx reactant-analyzer
Nothing to add to your project, nothing to configure. Node 20 or later. Vite and Next.js
projects are detected on their own, tsconfig paths included.
src/Dashboard.tsx owns the state src/Filters.tsx trims it and hands it back 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).
Object.is says it changed,
Dashboard re-renders, Filters gets a new value, the effect runs
again. It is a warning not an error because the analyzer saw the loop but could not prove that
nothing stops it.
Anything that leaves a file is followed to where it lands.
A setter handed down as a prop, a custom hook imported from another file, a helper that
wraps the hook call... and
--trace prints the steps that led there, with a file and line for each.
src/Cart.tsx Try it ! Cart (2 hooks) src/Cart.tsx1 error2 infinite-loop [hook:0] (line 6:2) this effect recreates object state `items` it depends on. Every run stores a fresh reference (`Object.is` always fails) and re-triggers itself: infinite render loop → a fresh value is written to state `items` here [hook:1] (line 7:4)3 ⚠ 1 error(s) across 1 file(s).
- The component that suffers the bug, and its file. In a two-file loop this is the child whose effect fires, not the parent that owns the state.
- How sure it is. error means the analyzer proved it. warn means it saw the shape but could not rule out a path that stops it, as with Filters above.
-
The step it took, printed by
--trace: the write or the read that keeps the finding alive, with its own line. A clean run prints only the component line and its tick.
It says what it read, and what it did not.
npx reactant-analyzer
Node 20 or later. Nothing is added to your ESLint config or your build. Pointing it at a
subdirectory (npx reactant-analyzer src/features) still runs inside the
project the config files describe.
✓ 37 file(s) no issues found. ⚠ 37 file(s), no findings, but parts of this run were not analyzed,
so this is not a clean bill.
not analyzed:
• no tsconfig `paths` found, so aliased imports (e.g. `@/...`) stay unresolved …
• 3 imported file(s) resolved outside the analysed set and were never read … Nineteen rules, sorted by what you see in the browser.
The left column is what you saw in the browser. The right is the rule that names it.
reactant rules lists them, plus two informational entries the run uses to say
where it could not look. reactant explain <rule> gives an example and a
fix for one.
| Loops that never settle | |
|---|---|
| The tab freezes, or React throws “Maximum update depth exceeded”. | infinite-loopan effect sets state that re-triggers the effect, and the value never settles |
| The same loop, but the setter belongs to the parent. Both files look fine alone. | cross-component-infinite-loopa child effect sets parent state, the parent re-renders the child, the effect fires again |
| A setter called while rendering. React warns, or loops. | setter-in-rendersetState runs during the render body |
| The same, but the setter arrived as a prop. | cross-setter-in-renderthe same, reached through a prop |
| Re-renders nobody asked for | |
| Typing in a field three components up re-renders the whole page on every keystroke. | state-lifted-too-higha state is used only deep in one child subtree, so every component above it re-renders to pass it down |
| The heavy sibling next to the search box renders identical output many times a second. | wasted-subtree-rendertyping, scrolling or pointer motion writes a state and re-renders subtrees that do not depend on it |
| Every consumer re-renders every time the provider does, even when nothing in the value changed. | unstable-context-valuea provider hands consumers a new object every render |
| State that should not be state | |
| One render shows the old value before the new one catches up. | derived-statean effect only mirrors another state, so compute it during render |
| Two renders where one would do, on every mount. | unnecessary-rerendera mount-only effect immediately overwrites the initial state |
| Nothing happens, and nobody remembers why the call is there. | redundant-set-statesetState is called with the value the state already holds |
| Values frozen in time | |
| The interval logs the same count forever. | stale-closurea long-lived callback keeps reading a value frozen from the moment the callback was created |
| The prop changed but the component still shows the first value. | frozen-initial-stateuseState is seeded from a prop that later changes, so the state sticks at the first value |
| Identity and mutation | |
| You pushed to the array and nothing re-rendered. | state-mutationa state or prop object is mutated in place, so the reference never changes and React skips the re-render |
| The expensive setup runs on every render instead of once. | lazy-inita useState initializer calls a function on every render |
| Lifecycle and environment | |
| Listeners pile up after each remount, twice under StrictMode. | missing-cleanupan effect starts something long-lived and never cleans it up |
| A hook in a Next.js Server Component, where hooks do not exist. | server-component-hooka hook runs in a Next.js Server Component |
| Also covered by ESLint, followed further | |
| The deps array misses a value, but the read is inside a helper or a custom hook in another file. | missing-depsexhaustive-deps, followed through helpers and other files |
| A dep is a fresh object or function every render, so the effect re-runs every time. | always-unstable-depspartly covered by exhaustive-deps |
| A hook call that is conditional once you follow the value, not the syntax. | conditional-hookrules-of-hooks, followed when the condition is on a value, not only when the if is around the call |
The two render-cascade rules take options, in reactant.config.json or with
--rule-option: how deep a state must sit below its owner before
state-lifted-too-high speaks, how many renders a write must waste before either
rule does, and whether wasted-subtree-render also counts clicks.
reactant explain <rule> lists them with their defaults.
Keep ESLint. Keep the compiler. Add this before merge.
Keep eslint-plugin-react-hooks in the editor. It runs as you type and is right
about the rules of hooks inside a file. Keep React Compiler in the build if you use it: it
caches values where it compiles, but it cannot break a loop whose value keeps changing, and
it skips components it cannot compile without saying so. Add reactant before merge. It is
slower than a linter because it reads the whole project at once.
| where | tool | what it reads | one run |
|---|---|---|---|
| In the editor | eslint-plugin-react-hooks | the file you are typing in, as syntax | milliseconds |
| In the build | React Compiler | one function at a time, to memoize it | part of the build |
| Before merge | reactant | the whole project, as values across renders and files | 1.8 s for 528 files (excalidraw), 76 s for 4,195 (dub) |
05 In CI
The repository is also a GitHub Action. Every finding becomes a line annotation on the PR.
name: reactant
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rboudrouss/reactant-analyzer@v0.7.0
with:
# the project root, so aliases load
path: .
# warnings annotate, never fail the PR
fail-on: error --format json gives the same report to machines, one JSON document with the
steps behind every finding.
For code an assistant wrote
AI assistants write these shapes a lot, behind enough helpers that a pattern match never fires. Because the JSON carries the steps that led to each finding, 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 real, false,
or not worth fixing. reactant-rules writes custom rule packs.
Your team's own rules
You can write your own team conventions that ships as rule packs, in JSON or JavaScript, written against what the analyzer already worked out (which hook a value came from, what a setter writes) rather than against source patterns, so they survive refactoring. See docs/custom-rules.md.
When it says error, it is a bug. When it is not sure, it says warn.
An Error can only be produced from a proof. The code that reports rules has no way to mark something Error on a guess. So an Error is a bug in your code. If one ever is not, that is a bug in the analyzer.
Warnings can be wrong. A finding that turns out to be fine is always a Warning or below,
never an Error. Every known case is listed in the
limitations page with
its issue, and --fail-on error lets CI ignore Warnings while they still
annotate the PR. If one is wrong on your code,
open an issue: wrong
warnings are filed under precision-fp, measured, and listed on the
limitations page.
It runs your render in its head until the values stop moving.
reactant turns each component into a small model of its render and its effects, and works
out what every useState can hold from one render to the next
(abstract interpretation, in the textbook sense). It repeats the
render until nothing changes any more, then reports the shapes that never settle, go stale,
or re-render everyone. Setters are followed through props and imports, custom hooks are read
as if inlined, and the finding is placed on the component that suffers it.
An Error can only be produced from a proof; the analysis errs the other way (we try to be sound: it would rather warn too much than miss a bug, so what it misses is listed as a limit rather than hidden) . The rules for how React re-renders are taken from React-tRace, a formal model of hooks published at OOPSLA 2025, so the analyzer and React agree on what a re-render is.