Reactivity Edge Cases
Module 2 established that Vue 2 converts data properties into getter/setters via Object.defineProperty at initialization time. Two categories of mutation fall outside what that mechanism can observe, and Vue 2 documents them explicitly as known limitations — not bugs, but structural consequences of the design.
export default {
data() {
return {
product: { name: 'Widget', stock: 5 }
// 'discount' does NOT exist here
}
},
methods: {
applyDiscount() {
// BROKEN: silently non-reactive. Template referencing
// product.discount will never update.
this.product.discount = 0.1
// FIX 1 — Vue.set / vm.$set: installs the getter/setter retroactively
this.$set(this.product, 'discount', 0.1)
// FIX 2 — replace the whole object (new object = full walk of its keys)
this.product = { ...this.product, discount: 0.1 }
}
}
}export default {
data() {
return { items: ['a', 'b', 'c'] }
},
methods: {
brokenUpdate() {
this.items[0] = 'z' // BROKEN: not reactive, template won't update
this.items.length = 1 // BROKEN: not reactive either
},
fixedUpdate() {
this.$set(this.items, 0, 'z') // FIX: Vue.set understands array indices
this.items.splice(1) // FIX: splice is a patched mutator method
}
}
}| Operation | Reactive? | Why |
|---|---|---|
| items.push(x) / .pop() / .splice() | Yes | Vue patches these 7 Array.prototype methods on the instance's array |
| items[i] = x | No | Plain index assignment, no getter/setter installed per-index |
| items.length = n | No | Length assignment doesn't route through any patched method |
| Vue.set(items, i, x) / this.$set(...) | Yes | Explicitly calls the patched splice internally to notify dependents |
| obj.newKey = value | No | Object.defineProperty was never called for a key that didn't exist at init |
| Vue.set(obj, 'newKey', value) | Yes | Explicitly walks the new key through defineReactive |
| delete obj.key | No | Plain delete doesn't notify the dependency tracker |
| Vue.delete(obj, 'key') / this.$delete(...) | Yes | Removes the key and explicitly triggers dep.notify() |
Render Functions & the Virtual DOM
Templates compile down to render functions that return VNodes — plain JavaScript objects describing what the DOM should look like. Writing render(h) by hand skips the template compiler entirely, useful for components whose output structure can't be expressed cleanly in template syntax (deeply dynamic tag names, heavy conditional branching, performance-critical list rendering).
// Simplified shape of what h() (createElement) returns —
// this is what your <template> compiles into behind the scenes.
const vnode = {
tag: 'div',
data: { staticClass: 'card', attrs: { id: 'product-1' } },
children: [
{ tag: 'span', data: {}, children: undefined, text: 'Widget' }
],
text: undefined,
elm: undefined, // populated with the real DOM node after patch
key: undefined,
componentOptions: undefined // populated instead of tag/children for component vnodes
}// Functional component: no instance, no `this`, no reactivity overhead —
// pure function of props+context to VNode. Ideal for presentational leaves
// rendered in bulk (table cells, list badges).
export default {
functional: true,
props: {
status: { type: String, required: true }
},
render(h, context) {
const { status } = context.props
const colorMap = { active: '#16a34a', paused: '#d97706', archived: '#6b7280' }
return h(
'span',
{
staticClass: 'badge',
style: { background: colorMap[status] ?? '#6b7280' }
},
context.children ?? status
)
}
}Practical Example: High-Performance Data Table via Manual render(h)
A table rendering thousands of rows through a template is workable, but a hand-written render function avoids per-row template overhead and gives full control over VNode reuse and key assignment for virtualization.
export default {
name: 'PerformantDataTable',
props: {
columns: { type: Array, required: true }, // [{ key, label, formatter? }]
rows: { type: Array, required: true },
rowHeight: { type: Number, default: 36 },
visibleCount: { type: Number, default: 20 } // simple windowing, not full virtual scroll
},
data() {
return { scrollTop: 0 }
},
computed: {
startIndex() {
return Math.max(0, Math.floor(this.scrollTop / this.rowHeight) - 2)
},
visibleRows() {
return this.rows.slice(this.startIndex, this.startIndex + this.visibleCount)
},
totalHeight() {
return this.rows.length * this.rowHeight
},
offsetY() {
return this.startIndex * this.rowHeight
}
},
methods: {
onScroll(e) {
this.scrollTop = e.target.scrollTop
},
renderHeaderRow(h) {
return h(
'div',
{ staticClass: 'pdt__row pdt__row--header' },
this.columns.map((col) =>
h('div', { key: col.key, staticClass: 'pdt__cell' }, col.label)
)
)
},
renderBodyRow(h, row) {
return h(
'div',
{ key: row.id, staticClass: 'pdt__row', style: { height: this.rowHeight + 'px' } },
this.columns.map((col) => {
const raw = row[col.key]
const value = col.formatter ? col.formatter(raw) : raw
return h('div', { key: col.key, staticClass: 'pdt__cell' }, String(value))
})
)
}
},
render(h) {
return h('div', { staticClass: 'pdt', on: { scroll: this.onScroll } }, [
this.renderHeaderRow(h),
h('div', { staticClass: 'pdt__viewport', style: { height: this.totalHeight + 'px', position: 'relative' } }, [
h(
'div',
{ style: { transform: `translateY(${this.offsetY}px)`, position: 'absolute', width: '100%' } },
this.visibleRows.map((row) => this.renderBodyRow(h, row))
)
])
])
}
}<style scoped>
.pdt { max-height: 480px; overflow-y: auto; border: 1px solid #ddd; font-family: system-ui, sans-serif; }
.pdt__row { display: flex; }
.pdt__row--header { position: sticky; top: 0; background: #f8f8f8; font-weight: 600; z-index: 1; }
.pdt__cell { flex: 1; padding: 0.5rem 0.75rem; border-bottom: 1px solid #eee; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>