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

  1. A missing .value. In script a ref is 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.
  2. A destructured or reassigned reactive object. Destructuring copies the current values out, so the copies never change. Reassigning the variable leaves the template bound to the old object. toRefs fixes the first; a ref holding the object fixes the second.
  3. 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.
  4. A v-for without :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.
  5. v-if and v-for on one element. v-if is evaluated first, so it cannot see the loop variable and throws. Filter in a computed, or move the v-for to 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

Example · vue
<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.

Example · ts
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: Lovelace

defineModel 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.

Example · vue
<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.

Example · ts
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

  • Be the first to comment on this lesson.