GitHub
Open Github

Render-Xray

X-ray vision for React re-renders. See exactly which prop, state, or function reference caused a re-render — and whether it was even necessary.

Installation

pnpm add @hookraft/render-xray

useRenderXray

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
    : () => noop

Strict 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:

PropTypeDefault
🟢 expected
--
🟡 avoidable
--
🔴 expensive
--

Change reasons

PropTypeDefault
value-changed
🟢 expected
-
object-value-changed
🟢 expected
-
same-value-new-reference
🟡 avoidable
-
new-function-reference
🟡 avoidable
-
added
🟢 expected
-
removed
🟢 expected
-

Options

PropTypeDefault
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

PropTypeDefault
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

FrameworkSupported
Next.js (app + pages router)
Vite + React
Remix
Create React App
React Native
React Compiler
React 17 / 18 / 19