Which Vue props actually waste a re-render? I measured them all
Everyone says “don’t pass inline objects, they re-render”. I measured 28 patterns. The advice is mostly wrong, the real footgun is narrower and sneakier, and in Vapor mode the whole thing just disappears.
There’s a bit of Vue folklore that goes: “never pass an inline object as a prop, it makes the child re-render every time”. It shows up in code reviews, style guides, the lot. I always half-believed it and never checked. So I built a harness, measured 28 patterns two independent ways, and it turns out the folklore is mostly wrong. The real trap is narrower, sneakier, and worth actually knowing.
First, what are we even talking about
A “wasted” re-render is when a parent updates, and a child re-renders even though nothing the child cares about changed. Vue is normally smart about this: a child only re-renders if its props changed by identity. So the question is really “which of the ways I write props accidentally hand the child a brand new identity every render?”.
Here’s the thing in motion. Hit the button. It changes a piece of state that none of these children use. Watch who re-renders anyway.
interactive · numbers from the measured harness
Dynamic inline prop
:config="{ id: item.id }"
0
never re-rendered
Stabilised
computed(() => ({ id }))
0
never re-rendered
Vapor mode
same code, fine-grained
0
never re-rendered
You changed something the children don’t even use. Only the dynamic inline prop keeps re-rendering. Same UI, same data, pure wasted work.
Only the dynamic inline prop keeps going. And that’s the whole story in one widget: it’s not inline objects that are the problem, it’s dynamic ones.
Static inline props are completely free
This surprised me. In a real SFC build (the Vite / vue-loader setup everyone actually ships), a static inline object gets compiled like this:
the child vnode has no patch flag// <Child :config="{ a: 1 }" />
_createBlock(_component_Child, { config: { a: 1 } })
// no patch flag, no dynamicProps ^No patch flag means the compiler decided this child is fully static, so the parent’s update skips it entirely. It never re-renders. Same goes for :items="[1,2,3]", :cls="{ active: true }", and even inline event handlers (those get cached). All the things the folklore warns about: fine.
But reference one reactive value and it flips
The moment your inline object touches a reactive value, it can’t be static any more:
now it's dynamic// <Child :config="{ a: label }" />
_createBlock(_component_Child, {
config: { a: _ctx.label }
}, null, 8 /* PROPS */, ["config"])
// dynamic ^ rebuilt every render, new identity every timeNow it’s rebuilt on every parent render, gets a fresh identity each time, and the child dutifully re-renders, even when label itself never changed. That’s the actual footgun, and it’s the common real-world shape: { id: item.id, active: isActive }, :items="list.filter(...)", :config="makeCfg()". Here’s the full measured catalogue:
measured · SFC build (hoistStatic + cacheHandlers)
:config="{ a: 1 }"static literal, compiler skips the child entirely:items="[1, 2, 3]"static literal, hoisted:cls="{ active: true }"static literal@evt="() => save()"cacheHandlers keeps it out of the diff:config="cfg" (ref/reactive)stable identity:config="computed(() => ...)"cached identity:config="{ id: item.id }"references reactive value, new object each render:items="[label]"dynamic array, new identityv-bind="{ ... }"spread, FULL_PROPS path:config="makeCfg()"method call, new object each render:items="list.filter(...)"new array every render:config="{ ...base, x: 1 }"spread of a dynamic source<Child v-for :config="{ a: i }"/>per-item object, new each renderDoes it actually matter?
A re-render that produces identical output still isn’t free. It re-runs the child’s render and re-diffs its whole subtree. I measured the overhead against a stabilised version, at a few child sizes:
measured · 30 children, avg update time, wasted ÷ fixed
The fixed version stays flat. The wasted one re-renders and re-diffs the whole child every parent update, so the bigger the child, the worse it gets.
On a small leaf it’s a rounding error. On a chunky child rendered 30 times, it’s real. And it only ever gets worse as the component grows.
The fixes (all take it back to zero)
Give the value a stable identity so the child stops seeing “new” props:
ts// instead of :config="{ id: item.id }"
const config = computed(() => ({ id: item.id }))
// or a ref / reactive you mutate, or hoist a truly-constant object out
// last resort when you can't restructure the prop:
// <Child :config="{ id: item.id }" v-memo="[item.id]" />A computed is the cleanest: it’s cached, so its identity only changes when its dependencies do. I measured v-memo too, and yes, v-memo="[item.id]" takes it to zero, but reach for it last.
The kicker: in Vapor, none of this exists
I compiled the exact same footgun for Vue 3.6’s Vapor mode and measured it. Vapor is pull-based, there’s no parent “re-render” shoving new props downward, so the child only does work when a value it actually reads changes:
same :config={ a: label }, 5 unrelated updatesmode child work on unrelated update on a real change
vdom 5 (wasted) 5
vapor 0 (free) 5Zero on the unrelated updates, but still 5 when the value it depends on actually changes, so it’s not cheating by never updating. The entire class of bug just evaporates. (More on Vapor in the tax post.)
What this actually means for you
Honestly? Most days, nothing. Vue already handles the common stuff. You don’t need to memorise a table or sprinkle computed everywhere. There’s basically one habit worth having:
If you’re building an object or array out of your data, right there in the template, and handing it to a child component, pull it into a computed instead.That’s the whole thing. In practice:
<UserCard :profile="{
name: user.name,
role: user.role
}" />
<TodoList
:items="todos.filter(t => !t.done)" />const profile = computed(() => ({
name: user.name,
role: user.role
}))
const active = computed(
() => todos.filter(t => !t.done))
// pass :profile="profile" :items="active"How to spot it in your own code, the tell-tale signs in a template:
- a
{ ... }or[ ... ]passed to a child that mentions a variable ({ id: item.id }) - a
.filter(...),.map(...)or.slice(...)in the template - calling a method for a prop value (
:config="makeCfg()") v-bind="{ ... }"with an inline object
And even then, only bother if that child is heavy or repeated (a big list row, a chart, a card you render 50 times). For a small leaf component it’s a rounding error, leave it alone and move on. If you’re on Vapor, you can forget the whole thing.
Don’t want to keep this in your head? I turned it into an ESLint rule. It flags exactly these patterns (inline objects, arrays, and .filter/.map calls handed to a component) and leaves the safe ones alone:
One rule, no-unstable-component-props. Catches the footguns from this post so you don’t have to eyeball every template.
One honest confession: I got this wrong on my first pass. My first harness used Vue’s runtime compiler, which doesn’t hoist statics or cache handlers, so it made half the “safe” patterns look guilty. Measuring the way real apps actually compile flipped five results. That’s the reason the advice above is “one habit”, not the scary “never use inline objects” version you usually hear.
All 28 scenarios, the SFC-accurate and Vapor harnesses, the cost benchmark, and the full running log (including the wrong first pass).
Measured on vue 3.5.41 (and 3.6.0-rc.5 for Vapor), SFC-accurate compilation, two independent signals cross-checked. I built this with Claude Code running the harness while I steered and made it re-measure once the first answer looked too good. Reproducible from the repo above.