reactant

A static analyzer for React hook bugs

Finds the bugs ESLint has no rule for: infinite render loops, effects that mirror state instead of computing it, callbacks stuck reading a value frozen at mount, providers that re-render every consumer on every render.

npx reactant-analyzer

Nothing to install, nothing to configure. Node 20 or later. Vite and Next.js projects are detected on their own, tsconfig paths included.

v0.6.0 MIT Node 20+ no config Changelog

src/Cart.tsx
reactant check .
Loading…
528 files in 1.8s
excalidraw, whole-program, from cold
4,195 files in 76s
dub, the same run on a monorepo
17 diagnostics
most with no ESLint counterpart

A whole-program fixpoint, not a pass per file.

Two correct files, one infinite loop

Dashboard owns the state. Filters trims it and hands it back. Neither file is wrong on its own.

src/Dashboard.tsx
src/Filters.tsx
reactant check .
  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

Rust Abstract interpretation WebAssembly React MIT licensed