Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Vue 3.5+ Composition API reference with progressive sub-file loading for components, composables, reactivity, and testing.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/gotchas.md
1# Vue Common Gotchas & Edge Cases23Critical Vue 3 gotchas that cause silent failures or hard-to-debug issues.45> Based on [vuejs-ai/skills](https://github.com/vuejs-ai/skills) vue-best-practices. For comprehensive coverage (200+ rules), see the upstream repo.67## Reactivity89### Always Use `.value` When Accessing ref() in Scripts1011**Impact: HIGH** - Forgetting `.value` causes silent failures.1213```ts14const count = ref(0)1516// WRONG17count++ // Tries to increment the ref object18count = 5 // Reassigns variable, loses reactivity19items.push(4) // Error: push is not a function2021// CORRECT22count.value++23count.value = 524items.value.push(4)2526// In templates - NO .value needed (Vue unwraps automatically)27// {{ count }} works, not {{ count.value }}28```2930### Never Destructure reactive() Objects Directly3132**Impact: HIGH** - Destructuring breaks reactive connection.3334```ts35const state = reactive({ count: 0, name: 'Vue' })3637// WRONG - destructured variables lose reactivity38const { count, name } = state39state.count++40console.log(count) // Still 0!4142// CORRECT - use toRefs()43const { count, name } = toRefs(state)44state.count++45console.log(count.value) // 14647// BEST - just use ref() instead of reactive()48const count = ref(0)49const name = ref('Vue')50```5152### Proxy Identity Hazard with reactive()5354```ts55const raw = {}56const proxy = reactive(raw)5758// WRONG - comparing different objects59console.log(proxy === raw) // false6061// WRONG - creating multiple proxies62const a = reactive({})63const b = reactive(a) // Returns same proxy64console.log(a === b) // true (same object)6566// GOTCHA - nested objects get proxied too67const nested = reactive({ obj: {} })68console.log(nested.obj === nested.obj) // true (same proxy)69```7071## Computed Properties7273### No Side Effects in Computed Getters7475**Impact: HIGH** - Side effects break reactivity model.7677```ts78// WRONG - mutates state79const doubled = computed(() => {80count.value++ // Side effect!81return count.value * 282})8384// WRONG - async operation85const data = computed(async () => {86return await fetch('/api') // Side effect!87})8889// CORRECT - pure computation only90const doubled = computed(() => count.value * 2)9192// For side effects, use watch:93watch(count, (newVal) => {94document.title = `Count: ${newVal}`95})96```9798### Computed Returns Are Read-Only99100```ts101const fullName = computed(() => `${first.value} ${last.value}`)102103// WRONG - computed values are read-only104fullName.value = 'John Doe' // Error!105106// CORRECT - use writable computed107const fullName = computed({108get: () => `${first.value} ${last.value}`,109set: (val) => {110const [f, l] = val.split(' ')111first.value = f112last.value = l113}114})115```116117## Watchers118119### Clean Up Async Operations to Prevent Race Conditions120121**Impact: HIGH** - Stale requests can overwrite newer data.122123```ts124const query = ref('')125const results = ref([])126127// WRONG - race condition128watch(query, async (q) => {129const res = await fetch(`/api?q=${q}`)130results.value = await res.json() // May overwrite newer results!131})132133// CORRECT - use onWatcherCleanup (Vue 3.5+)134watch(query, async (q) => {135const controller = new AbortController()136onWatcherCleanup(() => controller.abort())137138try {139const res = await fetch(`/api?q=${q}`, { signal: controller.signal })140results.value = await res.json()141} catch (e) {142if (e.name !== 'AbortError') throw e143}144})145146// Or use onCleanup parameter147watch(query, async (q, oldQ, onCleanup) => {148const controller = new AbortController()149onCleanup(() => controller.abort())150// ... same as above151})152```153154### Deep Watch Returns Same Object Reference155156```ts157const obj = reactive({ nested: { count: 0 } })158159// GOTCHA - oldValue === newValue for deep watches160watch(obj, (newVal, oldVal) => {161console.log(newVal === oldVal) // true! Same object162}, { deep: true })163164// If you need old value, clone first:165watch(166() => structuredClone(obj),167(newVal, oldVal) => { /* now different */ }168)169```170171## Props172173### Props Are Read-Only - Never Mutate174175**Impact: HIGH** - Breaks one-way data flow.176177```ts178const props = defineProps<{ count: number; user: User }>()179180// WRONG - direct mutation181props.count++ // Vue warning182props.user.name = 'New' // No warning but still wrong!183184// CORRECT - emit to parent185const emit = defineEmits(['update:count', 'update-user'])186emit('update:count', props.count + 1)187emit('update-user', { ...props.user, name: 'New' })188189// Or create local copy190const localUser = ref({ ...props.user })191```192193### Destructured Props Don't Update Watchers (pre-3.5)194195```ts196// WRONG (Vue < 3.5)197const { count } = defineProps<{ count: number }>()198watch(count, () => {}) // Won't trigger!199200// CORRECT - use getter201const props = defineProps<{ count: number }>()202watch(() => props.count, () => {})203204// Vue 3.5+ - destructuring works with reactive props205const { count } = defineProps<{ count: number }>()206watch(() => count, () => {}) // Works in 3.5+207```208209## Lifecycle Hooks210211### Register Hooks Synchronously During Setup212213**Impact: HIGH** - Async hooks silently fail.214215```ts216// WRONG - hook registered after await217async setup() {218const data = await fetchData()219onMounted(() => {}) // Will NEVER run!220}221222// WRONG - hook in setTimeout223setup() {224setTimeout(() => {225onMounted(() => {}) // Will NEVER run!226}, 100)227}228229// CORRECT - register synchronously, async inside230setup() {231onMounted(async () => {232const data = await fetchData()233})234}235```236237## Templates238239### Never Use v-if with v-for on Same Element240241**Impact: HIGH** - Vue 2/3 precedence differs.242243```vue244<!-- WRONG - ambiguous precedence -->245<li v-for="user in users" v-if="user.active" :key="user.id">246247<!-- Vue 3: v-if runs FIRST, 'user' undefined! -->248249<!-- CORRECT - computed filter -->250<li v-for="user in activeUsers" :key="user.id">251252<script setup>253const activeUsers = computed(() => users.filter(u => u.active))254</script>255256<!-- CORRECT - template wrapper -->257<template v-for="user in users" :key="user.id">258<li v-if="user.active">{{ user.name }}</li>259</template>260```261262### Template Refs Are Null with v-if263264```ts265const inputRef = ref<HTMLInputElement | null>(null)266267// GOTCHA - ref is null when element hidden268<input v-if="show" ref="inputRef" />269270// WRONG - may be null271inputRef.value.focus() // Error if !show272273// CORRECT - null check274inputRef.value?.focus()275276// Or use watchEffect with flush: 'post'277watchEffect(() => {278inputRef.value?.focus()279}, { flush: 'post' })280```281282## defineModel283284### Object Mutations Don't Emit285286```ts287const model = defineModel<{ name: string }>()288289// WRONG - mutation doesn't notify parent290model.value.name = 'New' // Parent won't know!291292// CORRECT - replace entire object293model.value = { ...model.value, name: 'New' }294```295296### Updated Value Needs nextTick297298```ts299const model = defineModel<string>()300301// WRONG - value not updated yet302model.value = 'new'303console.log(model.value) // Still old value!304305// CORRECT - wait for nextTick306model.value = 'new'307await nextTick()308console.log(model.value) // Now 'new'309```310311## Component Events312313### Undeclared Emits Can Fire Twice314315```ts316// WRONG - missing emit declaration causes double firing317const emit = defineEmits([]) // 'click' not declared318<button @click="emit('click')"> // Fires twice!319320// CORRECT - declare all custom events321const emit = defineEmits(['click'])322```323324### Events Don't Bubble Through Components325326```vue327<!-- Parent can't listen to grandchild events directly -->328<Grandparent>329<Parent>330<Child @custom="handler" /> <!-- Only Parent can listen -->331</Parent>332</Grandparent>333334<!-- Solution: re-emit or use provide/inject -->335```336337## Provide/Inject338339### Reactivity Not Automatic340341```ts342// Provider343const count = ref(0)344provide('count', count) // Pass the ref, not .value345346// Consumer347const count = inject('count') // Receives the ref348console.log(count.value) // Reactive!349350// WRONG - loses reactivity351provide('count', count.value) // Just passes number352```353354### Must Call Provide Synchronously355356```ts357// WRONG - provide after async358async setup() {359await fetchData()360provide('key', value) // Silently fails!361}362363// CORRECT364setup() {365provide('key', value) // Synchronous366onMounted(async () => {367await fetchData()368})369}370```371372## SSR373374### Lifecycle Hooks Don't Run on Server375376```ts377// onMounted, onUpdated, onUnmounted - client only378onMounted(() => {379// Only runs in browser380window.addEventListener('resize', handler)381})382383// For SSR, use onServerPrefetch for data384onServerPrefetch(async () => {385data.value = await fetchData()386})387```388389### Hydration Mismatch Causes390391Common causes:392393- Browser-only APIs (`window`, `localStorage`)394- Different timestamps395- Random values396- User-agent specific rendering397398```ts399// WRONG400const width = ref(window.innerWidth) // undefined on server401402// CORRECT403const width = ref(0)404onMounted(() => {405width.value = window.innerWidth406})407```408409## Performance410411### Use shallowRef for Large Non-Reactive Data412413```ts414// WRONG - deep reactivity overhead415const hugeList = ref(thousandsOfItems)416417// CORRECT - only track .value assignment418const hugeList = shallowRef(thousandsOfItems)419420// Trigger update by replacing entire array421hugeList.value = [...hugeList.value, newItem]422```423424### markRaw for Non-Reactive Objects425426```ts427// WRONG - Chart.js instance becomes reactive (breaks it)428const chart = ref(new Chart(ctx, config))429430// CORRECT - mark as non-reactive431const chart = ref(markRaw(new Chart(ctx, config)))432```433434## References435436- [vuejs-ai/skills vue-best-practices](https://github.com/vuejs-ai/skills/tree/main/skills/vue-best-practices) - Full 200+ rules437- [Vue Style Guide](https://vuejs.org/style-guide/)438- [Vue 3 Migration Guide](https://v3-migration.vuejs.org/)439