The Mistakes That Look Like Bugs
Five everyday errors that produce no stack trace: a destructured reactive, a missing .value, a mutated prop, a missing key, v-if with v-for.
Most Vue bugs are not framework faults. They are five specific mistakes, and four of them fail quietly, which is why they cost an afternoon.
The five
- A missing
.value. In script arefis read and written through.value; only the template unwraps it automatically.if (count)in script asks whether an object is truthy, and it always is. - A destructured or reassigned
reactiveobject. Destructuring copies the current values out, so the copies never change. Reassigning the variable leaves the template bound to the old object.toRefsfixes the first; arefholding the object fixes the second. - A mutated prop. Props are read-only and the development build warns. Emit an event so the owner changes its own state, or use
defineModel(stable since Vue 3.4) for a two-way value. - A
v-forwithout:key, or keyed by index. With an index key, removing the first item makes Vue patch every row instead of removing one, and component state follows the wrong item. Key by a stable id. v-ifandv-foron one element.v-ifis evaluated first, so it cannot see the loop variable and throws. Filter in acomputed, or move thev-forto a wrapping<template>.
Catching them earlier
TypeScript with vue-tsc catches a missing .value and a prop write before the browser does. Keep the development console open for the prop mutation warning, which is easy to scroll past. A missing or index-based :key prints no runtime warning at all, so catch that one with the vue/require-v-for-key ESLint rule instead.
Example
<script setup lang="ts">
import { computed, ref } from 'vue'
type Task = { id: string; title: string; done: boolean }
const props = defineProps<{
tasks: Task[]
showDone: boolean
}>()
const emit = defineEmits<{
toggle: [id: string]
}>()
const filter = ref('')
// Filtering here keeps v-if off the v-for element.
const visible = computed(() =>
props.tasks.filter((task) => {
if (!props.showDone && task.done) return false
return task.title.toLowerCase().includes(filter.value.toLowerCase())
})
)
function clearFilter(): void {
// .value in script, always.
filter.value = ''
}
</script>
<template>
<div>
<input v-model="filter" type="search" placeholder="Filter tasks" />
<button type="button" @click="clearFilter">Clear</button>
<ul>
<li v-for="task in visible" :key="task.id">
<label>
<input
type="checkbox"
:checked="task.done"
@change="emit('toggle', task.id)"
/>
{{ task.title }}
</label>
</li>
</ul>
</div>
</template>
<style scoped>
ul {
list-style: none;
padding: 0;
}
</style>When to use it
- Fix a counter that does not change when the click handler runs without an error.
- Explain why a checked checkbox moves to the wrong row after an item is deleted from a list.
- Replace a prop assignment that works in the browser but prints a read-only warning in the console.
More examples
toRefs instead of destructuring
toRefs turns every property of a reactive object into a ref that reads and writes through to the original. Use it whenever you want to pull named values out of a reactive object, which is most often when returning state from a composable so callers can destructure it safely.
import { computed, reactive, toRefs } from 'vue'
const state = reactive({ first: 'Ada', last: 'Lovelace' })
// Reactive: a ref that still points at state.first
const { first } = toRefs(state)
// Not reactive: a plain string copied out once
const { last } = state
const greeting = computed(() => `Hi ${first.value}`)
state.first = 'Grace'
state.last = 'Hopper'
console.log(greeting.value)
// Output: Hi Grace
console.log(last)
// Output: LovelacedefineModel instead of writing to a prop
defineModel, stable since Vue 3.4, declares the modelValue prop and its update event together and hands back a writable ref. Writing to that ref emits the update rather than mutating the prop, so the parent stays the owner of the value. Reach for it on any input wrapper the parent binds with v-model.
<script setup lang="ts">
const model = defineModel<string>({ required: true })
function clear(): void {
model.value = ''
}
</script>
<template>
<div>
<input v-model="model" type="text" />
<button type="button" @click="clear">Clear</button>
</div>
</template>
<style scoped>
div {
display: flex;
gap: 0.5rem;
}
</style>A ref when the whole object gets replaced
reactive() tracks one specific object; assigning a new object to the variable breaks every binding made to the old one. A ref holds whatever object you put in it, so replacing the contents is a normal write. Use this shape for state that arrives from an API as a fresh object each time.
import { computed, ref } from 'vue'
type Filters = { query: string; page: number }
// A reactive object cannot be reassigned without losing reactivity,
// so hold it in a ref when a response replaces it wholesale.
const filters = ref<Filters>({ query: 'vue', page: 1 })
const label = computed(() => `${filters.value.query} p${filters.value.page}`)
filters.value = { query: 'pinia', page: 3 }
console.log(label.value)
// Output: pinia p3
Discussion