Introduction
After 70 days of building Vue and Nuxt applications, you've probably shipped features, refactored components, and debugged state issues. Yet certain patterns keep surfacing in code reviews across teams: prop mutations that silently break parent state, destructured reactive values that stop updating, missing keys in loops that cause rendering bugs, and business logic tangled in templates that becomes impossible to test. Understanding common Vue mistakes: Vue & Nuxt best practices explained separates production-ready code from code that passes tests but fails under real-world pressure.
We'll walk through the four mistakes that catch even experienced engineers, show you exactly why they break, and demonstrate the patterns that prevent them.
Mutating Props
Props flow downward in Vue. They're read-only by design. Yet the temptation to mutate them directly is immediate and costs us every sprint.
Here's what happens: a parent passes a user object to a child component. The child modifies it directly. The parent's state appears to change, the UI updates, but you've violated the unidirectional data flow. Later, another sibling component receives a different version of that object. A third component expects the original value. Your application becomes a hall of mirrors where state is inconsistent across the tree.
The problematic pattern:
<template>
<div>
<input v-model="user.name" />
<button @click="user.email = 'new@example.com'">Update</button>
</div>
</template>
<script setup>
defineProps({
user: {
type: Object,
required: true
}
})
</script>This looks clean. It works. Then you need to sync changes back to the parent, or you need to cancel changes, or you need to validate before committing. You're now rewriting the component's entire logic.
The correct pattern:
<template>
<div>
<input :value="localUser.name" @input="updateName" />
<button @click="emitChanges">Update</button>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
user: {
type: Object,
required: true
}
})
const emit = defineEmits(['update:user'])
const localUser = ref({ ...props.user })
watch(() => props.user, (newUser) => {
localUser.value = { ...newUser }
}, { deep: true })
const updateName = (e) => {
localUser.value.name = e.target.value
}
const emitChanges = () => {
emit('update:user', localUser.value)
}
</script>Now you own the state. The parent retains control. Changes flow upward through emit, not sideways through mutation. When you need to validate, cancel, or sync with an API, you have a clear point of control.
For simple form bindings in Nuxt, use the v-model directive on custom components:
<!-- Parent -->
<UserForm v-model="userData" />
<!-- Child -->
<script setup>
const model = defineModel()
const updateField = (field, value) => {
model.value = { ...model.value, [field]: value }
}
</script>Losing Reactivity by Destructuring
Reactive objects are reactive because Vue wraps them. When you destructure them, you break that wrapper and pull out raw values. This is one of the most subtle bugs because it compiles, runs, and silently fails to update.
The problematic pattern:
<script setup>
import { reactive } from 'vue'
const state = reactive({
count: 0,
user: { name: 'Alice' }
})
// Destructuring breaks reactivity
const { count, user } = state
const increment = () => {
count++ // This doesn't trigger updates
}
const updateName = () => {
user.name = 'Bob' // This doesn't trigger updates
}
</script>
<template>
<div>
<p>{{ count }}</p> <!-- Won't update -->
<p>{{ user.name }}</p> <!-- Won't update -->
<button @click="increment">Increment</button>
<button @click="updateName">Update Name</button>
</div>
</template>The template still references the original reactive object through state.count, but the destructured local variable count is just a number. Updates to the number don't propagate.
The correct pattern:
<script setup>
import { reactive } from 'vue'
const state = reactive({
count: 0,
user: { name: 'Alice' }
})
const increment = () => {
state.count++ // Access through the reactive object
}
const updateName = () => {
state.user.name = 'Bob' // Access through the reactive object
}
</script>
<template>
<div>
<p>{{ state.count }}</p>
<p>{{ state.user.name }}</p>
<button @click="increment">Increment</button>
<button @click="updateName">Update Name</button>
</div>
</template>Or use ref if you prefer the destructuring syntax:
<script setup>
import { ref } from 'vue'
const count = ref(0)
const user = ref({ name: 'Alice' })
// Now you can destructure if needed
const { value: name } = user
const increment = () => {
count.value++
}
const updateName = () => {
user.value.name = 'Bob'
}
</script>This is also why toRefs exists. It converts reactive object properties into individual refs:
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({
count: 0,
user: { name: 'Alice' }
})
// Each property becomes a ref
const { count, user } = toRefs(state)
const increment = () => {
count.value++ // Now this works
}
</script>Missing Keys in v-for
Rendering lists without keys forces Vue to reuse DOM elements and component instances. This creates component state leakage, lost input focus, and re-ordered animations. It's particularly dangerous in Nuxt where components may have async setup.
The problematic pattern:
<template>
<div>
<div v-for="item in items">
<input :value="item.name" />
<button @click="remove(item.id)">Remove</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: 'First' },
{ id: 2, name: 'Second' },
{ id: 3, name: 'Third' }
])
const remove = (id) => {
items.value = items.value.filter(item => item.id !== id)
}
</script>When you remove the second item, Vue reuses the first two DOM nodes for items 1 and 3. If those input fields had focus or unsaved text, that state persists attached to the wrong item.
The correct pattern:
<template>
<div>
<div v-for="item in items" :key="item.id">
<input :value="item.name" />
<button @click="remove(item.id)">Remove</button>
</div>
</div>
</template>Use a stable, unique identifier. Never use the array index as a key unless the list is static and never reordered. In Nuxt applications with server-side rendering, missing keys can cause hydration mismatches when data changes between server and client.
Heavy Logic in Templates
Templates are for layout, not computation. Complex conditionals, filters, and transformations in the template are hard to test, slow to render, and impossible to reuse. They also make templates verbose and harder to follow.
The problematic pattern:
<template>
<div>
<div v-if="user.role === 'admin' && user.status === 'active' && !user.suspended">
<p>Admin Panel: {{ user.profile.details.settings.theme }}</p>
<ul>
<li v-for="item in items.filter(i => i.type === 'report').sort((a, b) => b.date - a.date).slice(0, 5)">
{{ item.title }} - {{ new Date(item.date).toLocaleDateString() }}
</li>
</ul>
</div>
</div>
</template>This is hard to read, impossible to unit test, and re-evaluates on every render pass.
The correct pattern:
<template>
<div>
<div v-if="isAdminAndActive">
<p>Admin Panel: {{ adminTheme }}</p>
<ul>
<li v-for="report in recentReports" :key="report.id">
{{ report.title }} - {{ formatDate(report.date) }}
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
user: { type: Object, required: true },
items: { type: Array, required: true }
})
const isAdminAndActive = computed(() => {
return (
props.user.role === 'admin' &&
props.user.status === 'active' &&
!props.user.suspended
)
})
const adminTheme = computed(() => {
return props.user.profile?.details?.settings?.theme || 'default'
})
const recentReports = computed(() => {
return props.items
.filter(item => item.type === 'report')
.sort((a, b) => b.date - a.date)
.slice(0, 5)
})
const formatDate = (date) => {
return new Date(date).toLocaleDateString()
}
</script>Computed properties are cached based on their dependencies. They're testable. They're readable. In Nuxt, this pattern also helps with performance because computed values are cached during server-side rendering and reused on the client.
Common Mistakes
- Assuming mutations work: They compile without errors but silently break state flow. Always respect unidirectional data flow.
- Destructuring reactive objects in setup: The values lose their reactive wrapper. Use
toRefsor access through the parent object. - Relying on array indices as keys: They work until your list reorders or filters, then component state gets scrambled. Use unique IDs.
- Treating templates as computation layers: Shift logic to computed properties and methods. Templates become clear, code becomes testable.
- Ignoring Nuxt SSR implications: Hydration mismatches often stem from these same patterns. State must be consistent between server and client.
Key Takeaways
- Emit changes from child components rather than mutating props. This keeps state flow clear and enables validation, cancellation, and API sync.
- Access reactive objects through their reference, not through destructured values. Use
toRefsif you need destructuring syntax. - Always bind a unique, stable
:keyto list items. This prevents component state leakage and ensures correct DOM reuse. - Move logic out of templates into computed properties and methods. This improves readability, testability, and performance.
- Test these patterns early in Nuxt projects. Hydration mismatches often point back to one of these four mistakes on the server rendering path.






Comments
Loading comments…