react-render-detective
v0.7.0
Published
Know WHY your React components render — not just that they did.
Maintainers
Readme
React Render Detective
Know why your React components render.
📖 Website · Guide · API · Feasibility report · Benchmarks
Status: 0.7.0, early release. 76 tests pass on React 18 and 19; benchmarks and bundle budgets are green; the packed package is verified in a clean install for ESM, CJS and TypeScript consumers; and the demo dashboard has been driven end to end in Chrome, which found four real defects the jsdom suite had missed (see the changelog). Not yet exercised: Suspense and error-boundary edges, and any app that isn't the demo. Treat it as a preview — issues welcome.
Not just:
UserProfile rendered 47 times.But:
UserProfile rendered because `user` changed by reference.
Its values are identical. Dashboard recreated the object.Debug React rendering without scattering console.log through your components.
Install
npm install react-render-detectiveimport { init } from "react-render-detective";
if (process.env.NODE_ENV !== "production") {
init();
}Then wrap the components you're investigating:
import { withRenderDetective } from "react-render-detective";
const UserProfile = withRenderDetective(function UserProfile({ user, onSave }) {
return /* … */;
});That's it. No server, no account, no API key, no browser extension, no data leaves your machine.
▲ [RRD] UserProfile #47 Reason: prop changed (reference only) Changed: user Duration: 8.4msAsk for the whole story at any time:
import { explain } from "react-render-detective";
console.log(explain("UserProfile"));UserProfile
47 recorded renders
Why?
78% of renders followed `user` changing by reference while its contents stayed the same.
Breakdown
props 37 79%
parent 8 17%
mount 1 2%
state-or-external 1 2%
Reference-only prop changes
user 37 79% (object)
onSave 31 66% (function)
Cost
average 8.4ms
total 394.8ms
potentially avoidable 37 render(s), ~310.8ms
Next step
Find where `user` is created in Dashboard and stabilise it (useMemo, or pass the
primitive fields you use).
Confidence: highWhat makes this different
It answers causality, not counts:
| Question | Answer |
| --- | --- |
| What rendered? | component, render number, mount vs update |
| Why? | props · parent · context · state · which store selector — with the evidence |
| What changed? | per prop: value change vs reference-only change |
| Where from? | the nearest instrumented ancestor, and whether it re-rendered |
| How expensive? | subtree duration, and self duration with descendants subtracted |
| How sure are we? | every diagnosis carries high / medium / low |
| What next? | an evidence-based suggestion, or nothing |
And it refuses to guess. When the runtime cannot tell you why something rendered, it says:
Cause could not be determined reliably.Three rules it holds to, which most render-debugging advice does not:
- Rendering is not a bug. Output says render, potentially avoidable render, slow render — never "BAD RENDER".
- Memoization is a trade.
React.memo/useMemo/useCallbackare suggested only when the evidence supports them, with the cost shown so you can decide. - StrictMode is not a 2× regression. Double-invoked renders are labelled as development replays and excluded from every statistic.
Automatic instrumentation
Wrapping by hand only finds problems you already suspected. One line instruments the whole app and adds source locations:
// vite.config.ts
import { renderDetective } from "react-render-detective/vite";
export default defineConfig({ plugins: [renderDetective(), react()] });TableRow src/App.tsx:85:7
Why?
100% of updates followed `onSelect` changing by reference while its contents stayed the same.
Next step
Trace `onSelect` back from ProductTable (src/App.tsx:110:1), which passes it to TableRow,
and stabilise it where it is created (useCallback, or hoist it out of the component).Babel is supported too, for Next.js, webpack and Remix — see the guide. Dev-only; it removes itself from production builds.
Integration modes
withRenderDetective — most accurate
const UserProfile = withRenderDetective(UserProfileImpl, { name: "UserProfile" });Full diagnosis: per-prop diffing, parent attribution, Profiler timings.
<RenderDetective> — zero refactor
<RenderDetective name="UserProfile">
<UserProfile />
</RenderDetective>Timings and parent propagation, but it only sees the children element — it cannot attribute a
render to an individual prop.
useRenderDiagnostics — from inside
function UserProfile(props) {
const diagnostics = useRenderDiagnostics("UserProfile", props);
// …
}Catches state-driven renders too, but a hook cannot install a <Profiler> around its own
component, so no durations are available in this mode.
Naming what the runtime can't see
const [items, setItems] = useTrackedState("items", []); // proves a state-driven render
useTrackedContextValue("AuthContext", value); // inside your provider
useTrackedEffect("sync", () => { … }, [userId]); // which declared dep changedOverlay
if (process.env.NODE_ENV !== "production") {
const { mountOverlay } = await import("react-render-detective/overlay");
mountOverlay();
}A floating panel with live totals, the most expensive components, and the full explain() output
for whichever one you select. It renders in a shadow DOM outside your React tree — an inspector
that re-rendered the tree it measures would be measuring itself.
The overlay is optional and lazily imported; the core is fully usable from the console.
Configuration
init({
enabled: process.env.NODE_ENV !== "production",
mode: "console", // "silent" | "console" | "verbose"
include: [/^Dashboard/], // empty = everything
exclude: ["Icon", "Button"], // exclude wins over include
samplingRate: 1, // 0–1, decided once per component instance
maxEvents: 1000, // bounded ring buffer
slowRenderThreshold: 16,
thresholds: { monitor: 5, slow: 16, verySlow: 50, critical: 100 },
inspection: { depth: 1, maxObjectKeys: 20, maxArrayLength: 20, maxStringLength: 120 },
compareFunctionSource: false, // spot recreated inline closures (opt-in)
onEvent: (event) => {},
});init() is idempotent — Fast Refresh, duplicate module copies and repeated calls reconfigure the
single instance instead of stacking three copies of the debugger.
Full API: docs/API.md.
Safety
- Nothing leaves your machine. No network calls, no telemetry, no analytics, no storage.
- Off until you ask. Importing the package registers nothing.
init()is the switch, and it turns diagnostics on unless it can positively seeNODE_ENV === "production"— guard the call so your bundler removes it entirely. Withenabled: falsenothing is registered and no<Profiler>is mounted. - Fail-safe. Every instrumentation path is wrapped: a throwing getter, an exploding subscriber or an un-inspectable prop degrades the diagnostic, never your app.
- Bounded. Ring-buffered events, capped inspection depth/width, props released on unmount.
Cost
Measured against the built package (npm run bench, full numbers in
docs/BENCHMARKS.md):
| | | | --- | --- | | Per instrumented component | ~7µs structural + ~7–16µs recording, well inside the 0.1ms target | | Bundle, everything loaded | 12.4 KB gzip (core 7.3 · React integration 10.7 · overlay 10.1) | | Runtime dependencies | none |
Percentage overhead depends on what you instrument: wrapping every trivial leaf in a 5000-node tree is expensive, wrapping the twenty components you're investigating is not. The benchmark reports both, honestly.
Does acting on it help?
Identical interaction, same UI, with and without the three fixes the tool reports
(node bench/before-after.mjs):
| | before | after | | --- | ---: | ---: | | renders | 421 | 30 | | potentially avoidable | 370 | 18 | | remounts | 10 | 0 |
Row went from 400 renders to 0 once memo could actually hold. Render time fell only 24%,
because these rows are trivial — the counts are exact, the milliseconds are indicative, and
BENCHMARKS.md explains why that gap is the honest result rather than a
disappointing one.
Limitations
Read docs/FEASIBILITY.md — it classifies every feature as reliable, inferred, or impossible without React internals, and this package uses no private React APIs.
The headlines:
- Parent means nearest instrumented ancestor. Uninstrumented components in between are reported as such, not glossed over.
- Context subscriptions are not enumerable at runtime. Context-driven renders are correlation within one commit, reported at medium confidence, and only for contexts you track.
- State values are not readable without internals. An untracked state render is reported as
state-or-external— we say we cannot tell which, rather than guessing. useTrackedStatemust be called in an instrumented component. A hook cannot see its own caller, only the nearest instrumented ancestor.- Source locations come from the build plugin. Without it there is no runtime way to get them —
_debugSourcewas removed in React 19. - Self duration is an upper bound: React exposes subtree time, and we subtract the instrumented descendants we know about.
React support
React 16.9+ (Profiler, context, refs — all public API). Tested against React 18 and 19; see
docs/COMPATIBILITY.md for the one behavioural difference between them.
License
MIT
