Skip to content

Repository files navigation

Basis logo

react-state-basis

Runtime diagnostics for React state

Dev-time tool that records when state writes land - not the values - and flags repeated timing patterns: extra frames, correlated flags, context copies, and update fan-out.

npm version GitHub stars License: MIT

Live demo
pick a scene · open the preview console


React DevTools, why-did-you-render, and React Scan help answer: why did this component render?

Basis asks something else:

What updated, what updated with it, and what appears upstream?

Basis HUD showing state updates

The HUD shows individual writes as they happen. The console report looks at the observed update graph over a rolling window - patterns that a single interaction often hides.


Quick start

1. Install

npm i react-state-basis

2. Vite setup

Vite only for now. Next.js / SWC is not instrumented yet.

The Babel plugin labels hooks at build time. You keep importing from react.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { basis } from 'react-state-basis/vite';

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: [['react-state-basis/plugin']],
      },
    }),
    basis(),
  ],
});

3. Provider

import { BasisProvider } from 'react-state-basis';

root.render(
  <BasisProvider debug={true} showHUD={true}>
    <App />
  </BasisProvider>
);

showHUD={false} keeps diagnostics in the console only.

4. Try it

const [a, setA] = useState(0);
const [b, setB] = useState(0);

useEffect(() => {
  setB(a + 1);
}, [a]);

return <button onClick={() => setA(a + 1)}>Update</button>;

Click the button a few times. A typical console hit:

⚡ BASIS | DOUBLE RENDER
📍 Location: YourComponent.tsx
Issue: effect_L5 triggers b in a separate frame.
Fix: Derive b during the render phase (remove effect) or wrap in useMemo.

That “Fix:” line is a prompt from a rolling frame window, not a proof. Repeat the same interaction and see whether the pattern holds.

Detected patterns →


Reports

After using the app with debug={true}:

window.printBasisReport()  // ranked “start here” list
window.printBasisGraph()   // observed update graph
window.getBasisGraph()     // same graph as JSON
window.getBasisMetrics()   // engine timings

printBasisReport() and printBasisGraph() no-op unless debug is on. getBasisGraph() always returns the current snapshot.

Example graph (from the playground):

📊 BASIS | CAUSAL GRAPH  7 nodes · 7 edges · 2 sources · buffer window 50
parent → child = observed cause → update. (×N) = times in this window.
⚡ Event · 3 targets · ×2
    BooleanEntanglement.tsx → isLoading redundant
    BooleanEntanglement.tsx → isSuccess redundant
    BooleanEntanglement.tsx → hasData redundant
↯ WeatherLab.tsx → effect @ L7
    WeatherLab.tsx → fahrenheit (×2)

An edge is something Basis saw in this window. It is not a proof of causality. The word redundant in that dump means “these writes kept landing together,” not “delete this state.”

For tooling or a bug report:

import { getBasisGraph } from 'react-state-basis';
import type { BasisGraphJSON } from 'react-state-basis';

Zustand stores can sit on the same graph:

import { basisLogger } from 'react-state-basis/zustand';

Zustand example →


What Basis looks for

Basis does not decide whether your state is correct. It surfaces runtime patterns that are often worth a second look.

Pattern What Basis saw
Effect-driven extra frame An effect writes state after another update. If that value can be computed during render, the second paint may be unnecessary.
Correlated updates Two pieces of state repeatedly move in the same window (isLoading / isSuccess). Basis reports the correlation. It does not assume they should be merged.
Fragmented updates One interaction updates several components, contexts, or stores. Sometimes that is intentional. Sometimes one transition would be easier with a clearer owner.
Context and store mirroring A local hook repeatedly follows Context or a store. If the local value is not a draft or otherwise independent, the extra copy may be unnecessary.
Update origins When several writes land together, the graph points at what looks upstream instead of treating every downstream write as its own issue.

Detected patterns →


Signals, not proofs

Detections use timing, correlation, update order, roles, and graph structure. Valid React can still look busy.

Intentional sync, drafts, animations, reducers, stores, and coordinated transitions can all produce hits. Use the signal with your knowledge of the app.

Ignore a file:

// @basis-ignore

Useful for animation loops, third-party wrappers, and state you already know is coupled on purpose.


Performance and privacy

  • Privacy: timing, roles, and update relationships - not state values.
  • Development: hot path is fixed-size ring buffers; heavier work runs on idle. Benchmarks in tested scenarios stay under ~1ms per update cycle. Real cost depends on the tree.
  • Production: monitoring is off; production entry is a small shim.
  • Runaway updates: instrumentation stops itself if the app enters a recursive update loop, so Basis does not keep a frozen tab alive.

Benchmarks →


Used on real codebases

These are demonstrations of output, not a claim that every hit is a defect.


How it works

Updates are sampled on requestAnimationFrame. Same tick means the same paint frame. From those ticks Basis builds a short-lived directed graph: what fired, what followed, and which writes look like they share a cause.

It never reads values or parses dependency arrays. Long async gaps look unrelated. Same-frame coincidence can look related.

Wiki · Roadmap · Contributing


Built by LP · MIT License

About

Runtime diagnostics for React state. Tracks when state updates - not values - to surface redundant state and update chains.

Topics

Resources

Contributing

Stars

115 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages