icy.dev
← all posts
#vue#performance#components#measurement

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.

Sam KavanaghSenior Fullstack EngineerAug 23, 2026 · 9 min read

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

Who re-renders when nothing they use changed?

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

parent updates: 0

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 time

Now 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)

The full catalogue
free:config="{ a: 1 }"static literal, compiler skips the child entirely
free:items="[1, 2, 3]"static literal, hoisted
free:cls="{ active: true }"static literal
free@evt="() => save()"cacheHandlers keeps it out of the diff
free:config="cfg" (ref/reactive)stable identity
free:config="computed(() => ...)"cached identity
wastes:config="{ id: item.id }"references reactive value, new object each render
wastes:items="[label]"dynamic array, new identity
wastesv-bind="{ ... }"spread, FULL_PROPS path
wastes:config="makeCfg()"method call, new object each render
wastes:items="list.filter(...)"new array every render
wastes:config="{ ...base, x: 1 }"spread of a dynamic source
wastes<Child v-for :config="{ a: i }"/>per-item object, new each render

Does 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

Does it actually cost anything? (yes, and it scales)
10 nodes
3.8×
50 nodes
14.6×
200 nodes
32.7×

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)                        5

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

❌ rebuilds every render
<UserCard :profile="{
  name: user.name,
  role: user.role
}" />

<TodoList
  :items="todos.filter(t => !t.done)" />
✅ stable, computed once
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.