Installation
pnpm add @hookraft/render-xrayuseRenderXray
import { useRenderXray } from "@hookraft/render-xray"const { renderCount, stableRenderCount, history, clearHistory } = useRenderXray(
"MyComponent",
props,
{ count, open },
{ onlyAvoidable: true, track: true, onRender: (record) => console.log(record) }
)How it works
Every time React calls your component function, that is a render. When it calls it again, that is a re-render. Most re-renders are fine — but some happen for the wrong reason:
- An object prop was recreated with the same content but a new memory reference
- A function prop was recreated inline instead of being wrapped in
useCallback - A parent re-rendered and dragged the child along even though nothing changed
useRenderXray sits inside your component and watches every re-render. It diffs the previous props and state against the new ones, classifies exactly why each value changed, and logs it in the console — with the fix — when a re-render included avoidable work.
Parent re-renders
│
├── Child A — prop "user" changed: same value, new reference → useMemo
├── Child B — prop "onClick" changed: new function → useCallback
└── Child C — no changes at all → render source inferred as parent
All console groups are collapsed by default to keep output manageable in large apps.
Full Examples
Track props only
import { useRenderXray } from "@hookraft/render-xray"
function UserCard(props: { user: User; onClick: () => void }) {
useRenderXray("UserCard", props as Record<string, unknown>)
return <div onClick={props.onClick}>{props.user.name}</div>
}Track props and state
function SearchBox(props: { placeholder: string }) {
const [query, setQuery] = useState("")
const [focused, setFocused] = useState(false)
useRenderXray("SearchBox", props as Record<string, unknown>, { query, focused })
return (
<input
placeholder={props.placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
/>
)
}Only log avoidable recreations
Silence expected re-renders. Only surface the ones worth fixing.
useRenderXray("HeavyChart", props as Record<string, unknown>, {}, {
onlyAvoidable: true,
})Filter specific change categories
useRenderXray("MyComponent", props as Record<string, unknown>, {}, {
filter: {
includeProps: true,
includeState: true,
includeFunctions: false, // hide function-reference noise
includeParentTriggers: false, // hide parent-triggered renders
},
})Collect history for a custom overlay
function Dashboard(props: DashboardProps) {
const { history, clearHistory } = useRenderXray(
"Dashboard",
props as Record<string, unknown>,
{},
{ track: true }
)
return (
<>
<DashboardUI {...props} />
{process.env.NODE_ENV === "development" && (
<RenderHistory records={history} onClear={clearHistory} />
)}
</>
)
}Send records to a custom reporter
useRenderXray("MyComponent", props as Record<string, unknown>, {}, {
log: false,
onRender: (record) => {
if (record.hasAvoidableChanges) {
myAnalytics.track("avoidable_rerender", {
component: record.component,
trigger: record.trigger,
changes: record.changes.map((c) => c.key),
})
}
},
})What you see in the console
All groups are collapsed by default. Click to expand.
Avoidable — same value, new object reference
▶ [render-xray] UserCard re-rendered (props changed)
render #4
🟢 render duration: 2.1ms
🟡 [prop] user ← same value, new reference
fix: wrap in useMemo()
changed keys:
(none — content is identical)
⚠ 1 avoidable prop/value recreation detected during this render.
See flagged changes above for useMemo / useCallback opportunities.
Avoidable — new function on every render
▶ [render-xray] UserCard re-rendered (props changed)
render #5
🟢 render duration: 1.8ms
🟡 [prop] onClick ← new function on every render
fix: wrap in useCallback()
Parent triggered it — render source inferred
▶ [render-xray] UserCard re-rendered (render source inferred: parent)
render #6
🟢 render duration: 1.2ms
→ No local changes detected — render source inferred as parent.
Tip: this component may benefit from memoization (React.memo / equivalent).
Expected — value actually changed
▶ [render-xray] SearchBox re-rendered (state changed)
render #3
🟢 render duration: 3.4ms
🟢 [state] query ← changed
prev: "re"
next: "rea"
Expensive render (>16ms)
▶ [render-xray] HeavyChart re-rendered (props changed)
render #9
🔴 render duration: 24.6ms
🔴 [prop] data ← same value, new reference
fix: wrap in useMemo()
⚠ 1 avoidable prop/value recreation detected during this render.
React Strict Mode replay (dev only)
▶ [render-xray] MyComponent React StrictMode replay (dev only)
This is a development-only double-invoke. Ignore for production analysis.
Strip in production
useRenderXray is for development only. Two ways to remove it:
Option 1 — conditional call
if (process.env.NODE_ENV === "development") {
useRenderXray("MyComponent", props as Record<string, unknown>)
}Option 2 — no-op wrapper (recommended)
Create this file once in your project and import from it instead of the package directly.
// lib/renderXray.ts
type Hook = typeof import("@hookraft/render-xray").useRenderXray
const noop: ReturnType<Hook> = {
renderCount: 0,
stableRenderCount: 0,
history: [],
clearHistory: () => {},
}
export const useRenderXray: Hook =
process.env.NODE_ENV === "development"
? require("@hookraft/render-xray").useRenderXray
: () => noopStrict Mode handling
React Strict Mode intentionally double-invokes effects in development. render-xray detects replays within a 50 ms window and annotates them instead of logging them as real renders. stableRenderCount gives you an estimated production-equivalent count.
Severity levels
Every change carries a severity so you know what to prioritise:
| Prop | Type | Default |
|---|---|---|
🟢 expected | - | - |
🟡 avoidable | - | - |
🔴 expensive | - | - |
Change reasons
| Prop | Type | Default |
|---|---|---|
value-changed | 🟢 expected | - |
object-value-changed | 🟢 expected | - |
same-value-new-reference | 🟡 avoidable | - |
new-function-reference | 🟡 avoidable | - |
added | 🟢 expected | - |
removed | 🟢 expected | - |
Options
| Prop | Type | Default |
|---|---|---|
log | boolean | true |
track | boolean | false |
maxHistory | number | 50 |
onlyAvoidable | boolean | false |
onRender | (record: useRenderXray.Record) => void | - |
filter.includeProps | boolean | true |
filter.includeState | boolean | true |
filter.includeFunctions | boolean | true |
filter.includeParentTriggers | boolean | true |
Returns
| Prop | Type | Default |
|---|---|---|
renderCount | number | - |
stableRenderCount | number | - |
history | useRenderXray.Record[] | - |
clearHistory() | () => void | - |
TypeScript
All types live under the useRenderXray namespace. No extra imports needed.
import { useRenderXray } from "@hookraft/render-xray"
// Options
const options: useRenderXray.Options = { onlyAvoidable: true }
// Filter options
const filter: useRenderXray.FilterOptions = { includeFunctions: false }
// A single render record
const record: useRenderXray.Record = { ... }
// A single change within a record
const change: useRenderXray.Change = { ... }
// The trigger type
const trigger: useRenderXray.Trigger = "parent"
// The reason a value changed
const reason: useRenderXray.ChangeReason = "same-value-new-reference"
// The severity of a change
const severity: useRenderXray.Severity = "avoidable"Works with
| Framework | Supported |
|---|---|
| Next.js (app + pages router) | ✅ |
| Vite + React | ✅ |
| Remix | ✅ |
| Create React App | ✅ |
| React Native | ✅ |
| React Compiler | ✅ |
| React 17 / 18 / 19 | ✅ |