Massive components, reactive data you don’t need, watchers where computed props belong, v-if on the wrong element — a brutally honest breakdown of the Vue anti-patterns that silently kill your app’s performance and your team’s sanity.
Most Vue performance problems aren’t from bad algorithms or slow servers. They’re from small, habitual mistakes — patterns that feel intuitive in the moment and quietly accumulate into a sluggish, unmaintainable application. This is a breakdown of the most common ones, with the exact fix for each.
Anti-Pattern 1: Using Watchers Instead of Computed Properties
This is the most common Vue anti-pattern. When developers want a value that depends on other reactive state, they reach for watch. The impulse makes sense — you’re “watching” a change and producing a result. But watchers are for side effects. Computed properties are for derived state. These are not interchangeable.
<!-- ✗ Wrong — watcher computing derived state -->
<script setup lang="ts">
import { ref, watch } from 'vue'
const firstName = ref('Taylor')
const lastName = ref('Otwell')
const fullName = ref('') // ← unnecessary ref
const initials = ref('') // ← unnecessary ref
watch([firstName, lastName], () => {
fullName.value = `${firstName.value} ${lastName.value}`
}, { immediate: true })
watch(fullName, (name) => {
initials.value = name.split(' ').map(n => n[0]).join('')
}, { immediate: true })
</script>
<!-- ✓ Correct — computed properties for derived state -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const firstName = ref('Taylor')
const lastName = ref('Otwell')
// Lazy, memoised — only recalculates when dependencies change
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
const initials = computed(() =>
fullName.value.split(' ').map(n => n[0]).join('')
)
</script>
The rule: If a value is derived from other reactive state, it’s a computed property. If you need to run a side effect (API call, DOM manipulation, localStorage write) in response to a change, it’s a watcher. When in doubt, try computed first.
Anti-Pattern 2: Making Everything Reactive
Vue’s reactivity system has a cost — it proxies objects and tracks dependencies on every property access. Wrapping large, static, or constant objects in ref() or reactive() when they never change pays that cost for no benefit. It also causes unexpected issues when passing deeply nested objects to external libraries that don’t understand Vue proxies.
<!-- ✗ Wrong — reactive wrapping of static data -->
<script setup lang="ts">
import { reactive, ref } from 'vue'
// These never change — wrapping them is pure overhead
const COUNTRY_LIST = reactive([
{ code: 'US', name: 'United States' },
{ code: 'IN', name: 'India' },
// ... 200 more countries
])
const API_ENDPOINTS = reactive({
users: '/api/users',
posts: '/api/posts',
})
// This config is set once and never mutated
const chartConfig = ref({
type: 'bar', responsive: true,
plugins: { legend: { position: 'top' } },
})
</script>
<!-- ✓ Correct — only reactive data uses reactive APIs -->
<script setup lang="ts">
import { ref } from 'vue'
// Static — plain const, zero reactivity overhead
const COUNTRY_LIST = [
{ code: 'US', name: 'United States' },
{ code: 'IN', name: 'India' },
]
const API_ENDPOINTS = {
users: '/api/users',
posts: '/api/posts',
} as const
// Object.freeze() for immutable objects passed to external libraries
const chartConfig = Object.freeze({
type: 'bar', responsive: true,
plugins: { legend: { position: 'top' } },
})
// Only reactive data uses ref() or reactive()
const selectedCountry = ref('')
const chartData = ref([])
</script>
Extra tip: Use
Object.freeze()on objects passed to Chart.js, Three.js, or Leaflet. Frozen objects are not proxied by Vue’s reactivity system, preventing the proxy from interfering with the library’s internal mutation detection.
Anti-Pattern 3: v-if and v-for on the Same Element
Placing v-if and v-for on the same element is one of the most documented Vue mistakes — and still one of the most common. In Vue 3, v-if has higher priority than v-for. This means the v-if condition is evaluated before the loop variable exists, causing a runtime error or accessing a variable that is out of scope.
<!-- ✗ Wrong — v-if runs BEFORE v-for in Vue 3 -->
<template>
<!-- user is not defined when v-if evaluates -->
<li
v-for="user in users"
v-if="user.isActive"
:key="user.id"
>
{{ user.name }}
</li>
</template>
<!-- ✓ Option A — wrap with <template> to separate concerns -->
<template>
<template v-for="user in users" :key="user.id">
<li v-if="user.isActive">{{ user.name }}</li>
</template>
</template>
<!-- ✓ Option B — filter in a computed property (preferred) -->
<script setup lang="ts">
const activeUsers = computed(() => users.value.filter(u => u.isActive))
</script>
<template>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</template>
Anti-Pattern 4: Missing or Incorrect :key on v-for
Keys tell Vue’s virtual DOM diffing algorithm how to match old nodes to new nodes when a list changes. Without a key, Vue uses positional diffing — reusing the nearest node in the same position regardless of identity. Using index as a key has the same problem when items are reordered.
<!-- ✗ Wrong — index as key, no key at all -->
<template>
<!-- No key — positional diffing, stale state -->
<TodoItem v-for="todo in todos" :todo="todo" />
<!-- Index as key — same problem when items reorder -->
<TodoItem
v-for="(todo, index) in todos"
:key="index"
:todo="todo"
/>
</template>
<!-- ✓ Correct — stable, unique identity as key -->
<template>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
/>
</template>
When index keys cause real bugs: A user types into an input inside a list item. Another user prepends a new item at the top. With index keys, Vue reuses the first DOM node for the new item — and the text the user typed stays in the wrong input. With ID keys, Vue creates a new node for the new item and keeps existing nodes in place.
Anti-Pattern 5: Mutating Props Directly
Props are intended to flow one way — from parent to child. When a child mutates a prop directly, the data flow becomes invisible and debugging becomes a nightmare. Vue 3 will warn about this in development, but the mutation still works — making it easy to ship to production.
<!-- ✗ Wrong — mutating a prop directly -->
<script setup lang="ts">
const props = defineProps<{ count: number }>()
function increment() {
props.count++ // Vue warns, state flow becomes invisible
}
</script>
<!-- ✓ Option A — emit the change, parent owns state -->
<script setup lang="ts">
const props = defineProps<{ count: number }>()
const emit = defineEmits<{ (e: 'update:count', val: number): void }>()
function increment() {
emit('update:count', props.count + 1)
}
</script>
<!-- ✓ Option B — defineModel() for two-way binding (Vue 3.4+) -->
<script setup lang="ts">
const count = defineModel<number>('count')
function increment() {
count.value++ // Safe — defineModel emits under the hood
}
</script>
<!-- Parent usage -->
<Counter v-model:count="myCount" />
Anti-Pattern 6: The Monolith Component
A 600-line single-file component is not a component — it’s a module that refuses to be organised. Beyond readability, monolith components hurt performance: Vue’s component system is the unit of re-rendering. A single large component re-renders entirely when any reactive state changes. Smaller components re-render only when their own inputs change.
<!-- ✗ Wrong — one component doing everything -->
<!-- Dashboard.vue — 600+ lines -->
<template>
<!-- Sidebar navigation (80 lines) -->
<!-- Stats cards (120 lines) -->
<!-- Chart with config (150 lines) -->
<!-- Data table with sorting/pagination (200 lines) -->
<!-- Recent activity feed (80 lines) -->
</template>
<script setup lang="ts">
// 200+ lines of mixed concerns
</script>
<!-- ✓ Correct — decomposed into focused components -->
<!-- Dashboard.vue — orchestrator only (~40 lines) -->
<template>
<DashboardLayout>
<template #sidebar><DashboardSidebar :nav="navItems" /></template>
<StatsGrid :stats="dashboardStats" />
<RevenueChart :data="chartData" />
<OrdersTable :orders="recentOrders" />
<ActivityFeed :items="activity" />
</DashboardLayout>
</template>
<script setup lang="ts">
// Data fetching and orchestration only — no rendering logic
const { stats, chart, orders, activity } = useDashboard()
</script>
The single responsibility rule for components: If you can’t describe what a component does without using the word “and”, it needs to be split.
DashboardSidebarrenders the sidebar navigation.RevenueChartrenders the revenue chart. Each has one job, one reason to re-render, one reason to change.
Anti-Pattern 7: Immediate Watchers Instead of watchEffect
A common pattern for loading data on mount — and reloading it when a param changes — is watch with { immediate: true }. watchEffect expresses the same thing more concisely and automatically tracks dependencies without an explicit source array.
<!-- ✗ Verbose — watch with immediate: true -->
<script setup lang="ts">
const userId = ref(1)
const user = ref(null)
const loading = ref(false)
watch(
userId,
async (id) => {
loading.value = true
user.value = await fetchUser(id)
loading.value = false
},
{ immediate: true }
)
</script>
<!-- ✓ Cleaner — watchEffect with auto-tracked dependencies -->
<script setup lang="ts">
const userId = ref(1)
const user = ref(null)
const loading = ref(false)
// Runs immediately on mount, then whenever userId changes
// No need to specify [userId] as source — it's auto-tracked
watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort()) // cancel on re-run or unmount
loading.value = true
user.value = await fetchUser(userId.value, { signal: controller.signal })
loading.value = false
})
</script>
Anti-Pattern 8: Forgetting to Clean Up Side Effects
Watchers, event listeners, timers, and WebSocket connections created inside a component must be cleaned up when it unmounts. Failing to do so causes memory leaks — timers firing for destroyed components, event listeners accumulating on every mount/unmount cycle.
<!-- ✗ Wrong — side effects with no cleanup -->
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => {
window.addEventListener('resize', handleResize) // never removed
setInterval(pollData, 5000) // never cleared
const ws = new WebSocket('wss://api.example.com') // never closed
})
</script>
<!-- ✓ Correct — cleanup in onUnmounted or via composables -->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
let intervalId: ReturnType<typeof setInterval>
let ws: WebSocket
onMounted(() => {
window.addEventListener('resize', handleResize)
intervalId = setInterval(pollData, 5000)
ws = new WebSocket('wss://api.example.com')
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
clearInterval(intervalId)
ws.close()
})
// Even better: use composables that handle cleanup automatically
// useEventListener(window, 'resize', handleResize)
// useInterval(pollData, 5000)
</script>
Anti-Pattern 9: Destructuring reactive() Without toRefs()
reactive() is convenient for grouping related state, but it has a critical footgun: destructuring a reactive object breaks reactivity. The extracted properties become plain JavaScript values — not reactive refs. This is one of the most confusing bugs in Vue 3 codebases.
<!-- ✗ Wrong — destructuring reactive() loses reactivity -->
<script setup lang="ts">
import { reactive } from 'vue'
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = state
// count and name are now plain values — no longer reactive!
count++ // does NOT trigger re-render
state.count++ // this works, but count in the destructured binding won't update
</script>
<!-- ✓ Option A — use ref() for individual values (recommended) -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const name = ref('Vue')
count.value++ // always reactive
</script>
<!-- ✓ Option B — toRefs() if you need to destructure reactive() -->
<script setup lang="ts">
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, name: 'Vue' })
// toRefs() converts each property to a ref — destructuring is safe
const { count, name } = toRefs(state)
count.value++ // reactive — .value accesses the ref
</script>
Anti-Pattern 10: Using ref() for Large External Objects
When you pass a large object to ref(), Vue deeply proxies every nested property — recursively walking the entire object tree. For large datasets, chart instances, or third-party class instances, this is significant overhead and can cause issues when the external library mutates the object internally.
<!-- ✗ Wrong — deep reactive proxy on a large dataset -->
<script setup lang="ts">
import { ref } from 'vue'
// ref() deeply proxies ALL 10,000 rows — massive overhead
const tableData = ref(await fetchAllRows()) // 10,000 rows
// Map instance — deep proxy breaks Leaflet's internal logic
const mapInstance = ref(null)
onMounted(() => {
mapInstance.value = L.map('map') // Leaflet mutates internals — proxy breaks this
})
</script>
<!-- ✓ Correct — shallowRef only tracks the reference, not the contents -->
<script setup lang="ts">
import { shallowRef, triggerRef } from 'vue'
// shallowRef — only the reference itself is reactive, not the contents
const tableData = shallowRef(await fetchAllRows())
const mapInstance = shallowRef(null)
onMounted(() => {
mapInstance.value = L.map('map') // Leaflet manages its own internals safely
})
// When contents change and you need to trigger reactivity manually:
triggerRef(tableData) // tells Vue the data has changed
</script>
The Checklist
Run through this before your next Vue component ships:
- Every value derived from reactive state is a computed property, not a watcher
- Only state that actually changes is wrapped in
ref()orreactive()— static data is a plainconst v-ifandv-forare never on the same element- Every
v-forhas a:keyusing a stable unique identifier — neverindexwhen items have state or can reorder - Props are never mutated directly — use
emitordefineModel() - Components have one clear responsibility — “and” in the description means split it
watchEffectis used for immediate + reactive effects;watchis used when you need the old value or explicit source control- Every event listener, timer, and WebSocket opened in
onMountedis cleaned up inonUnmounted reactive()state that gets destructured usestoRefs()- Large datasets and third-party instances use
shallowRef()
Final Thoughts
None of the anti-patterns in this post are obscure edge cases. They are common, daily mistakes that silently degrade performance, make debugging harder, and accumulate into codebases that developers dread opening. The good news is that every one of them has a clear, mechanical fix.
Vue’s reactivity system is extraordinarily good at its job — but only if you understand which tools are designed for which problems. Computed properties for derived state. Watchers for side effects. shallowRef for external objects. defineModel for two-way binding. One component, one responsibility.
The patterns are learnable. The discipline is a choice. Make the right one and your Vue applications will be faster, smaller, and genuinely pleasant to work in.
