Vue tells you that a component re-rendered. It doesn't tell you why.
I kept hitting the same problem: a component re-renders, renderTriggered fires, but the payload says target: Proxy — useless. Which ref changed? Which prop got a new reference? Was it Pinia? The router? A computed three levels up?
So I built vue-why-render — a zero-dependency dev tool that names the exact reactive source behind every re-render.
Quick start
import { scan } from 'vue-why-render'
const app = createApp(App)
scan(app, { panel: true })
app.mount('#app')
That's it. Every component that re-renders now gets an overlay label:
ProductCard ×3 1.2 ms
prop badge: {text} → {text} — new reference, same value
💡 Object is rebuilt in parent — hoist to computed
You get the component name, the render count, the duration, the trigger — and an actionable hint when the render is avoidable.
The problem with Vue's built-in hooks
Vue 3 exposes renderTracked and renderTriggered on every component. In theory, they tell you what's going on. In practice:
-
targetis a rawProxy— you can't tell whichrefit came from - There's no diffing — you don't see old vs. new value
- Pinia stores show up as generic reactive objects
- Route changes show the entire
$routeobject, not which param changed - There's no way to see the dependency graph across components
vue-why-render hooks into these same APIs, but resolves the raw proxies back to named sources. When Pinia triggers a render, you see store cart.items: Array(1) → Array(0) · action clear(). When a route param changes, you see route.params.id: "1" → "2" — not $route: {...}.
What's in the panel
The floating panel has 5 tabs:
| Tab | What it shows |
|---|---|
| Top | Components sorted by render count |
| Slow | Components sorted by render duration |
| Tree | Component hierarchy with Σ render sums |
| Events | Chronological log of every render |
| Deps | Reactivity graph (new in 0.8) |
Reactivity graph
The Deps tab is the feature I'm most excited about. It builds a live dependency map: component ↔ reactive source.
Two views:
- Components → which refs, props, stores, and computeds does this component read?
- Sources → which components depend on this ref?
Every edge has a read count and a trigger count. If UserAvatar reads user.name 47 times but it only triggered 2 re-renders, you know the other 45 reads were harmless. If CartBadge gets triggered by cart.items on every keystroke — that's your problem.
It's like X-ray for Vue's reactivity system.
Why Rust/WASM
A real Vue app can have hundreds or thousands of components. Building the dependency tree, deduplicating render causes, and diffing reports across time — on every frame — adds up.
I rewrote the heavy algorithms in Rust and compiled them to WebAssembly:
- Tree construction
- Cause deduplication
- Report diffing
The WASM module is lazy-loaded as a separate chunk — it doesn't bloat your app bundle. If WASM isn't available (SSR, jsdom, strict CSP), a JS fallback kicks in automatically. No configuration needed.
VS Code extension
Browser devtools are great, but I kept switching between Chrome and VS Code. So I built a WebSocket bridge.
The VS Code extension connects to the scanner running in the browser and shows:
- Component tree in the sidebar — sorted by renders, with flame icons for hot components
- Deps tree — same dependency graph, but in the editor
-
CodeLens on
.vuefiles — render count and average duration, right on the component definition - Status bar — live render counter
- Commands — pause, resume, reset from the command palette
No browser tab switching. The data is right where you write code.
Testing renders
The scariest thing about render optimization is regression. You fix a wasted render today, someone adds a prop next week, and it's back.
vue-why-render ships test utilities:
import { expectRenders } from 'vue-why-render/test'
it('ProductCard does not re-render on unrelated state change', async () => {
// ... mount component, trigger action
expectRenders(ProductCard).toBe(0, 'adding to cart')
})
This runs in Vitest / Jest. If ProductCard re-renders when it shouldn't, the test fails — with the exact trigger in the error message. The fix stays fixed.
Render budgets
For runtime monitoring, you can set budgets:
scan(app, {
maxRenders: 10, // per 5-second window
maxDuration: 16, // ms per update
})
Violations show up in the console, the panel, and the reports. Useful for catching regressions in staging before they hit production.
Reports
Export render data as JSON or Markdown:
import { report } from 'vue-why-render'
const data = report('json') // structured data
const md = report('markdown') // ready for GitHub issues
You can diff two reports to catch regressions between builds.
Nuxt support
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['vue-why-render/nuxt']
})
Listed on nuxt.com/modules.
Try it
- Live demo: dadashi44.github.io/vue-why-render
- GitHub: github.com/dadashi44/vue-why-render
-
Install:
npm i vue-why-render
Zero runtime dependencies. MIT license. Vue 3.3+.
If you find it useful — a ⭐ on GitHub genuinely helps with discoverability. And if you hit a bug or want a feature, issues are open.
Top comments (0)