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.
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?
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.
npm i react-state-basisVite 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(),
],
});import { BasisProvider } from 'react-state-basis';
root.render(
<BasisProvider debug={true} showHUD={true}>
<App />
</BasisProvider>
);showHUD={false} keeps diagnostics in the console only.
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.
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 timingsprintBasisReport() 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';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. |
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-ignoreUseful for animation loops, third-party wrappers, and state you already know is coupled on purpose.
- 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.
These are demonstrations of output, not a claim that every hit is a defect.
- shadcn-admin #274 - redundant viewport state; merged.
- Excalidraw #10637 - theme sync pattern; not merged.
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

