# Vue Common Gotchas & Edge Cases Critical Vue 3 gotchas that cause silent failures or hard-to-debug issues. > Based on [vuejs-ai/skills](https://github.com/vuejs-ai/skills) vue-best-practices. For comprehensive coverage (200+ rules), see the upstream repo. ## Reactivity ### Always Use `.value` When Accessing ref() in Scripts **Impact: HIGH** - Forgetting `.value` causes silent failures. ```ts const count = ref(0) // WRONG count++ // Tries to increment the ref object count = 5 // Reassigns variable, loses reactivity items.push(4) // Error: push is not a function // CORRECT count.value++ count.value = 5 items.value.push(4) // In templates - NO .value needed (Vue unwraps automatically) // {{ count }} works, not {{ count.value }} ``` ### Never Destructure reactive() Objects Directly **Impact: HIGH** - Destructuring breaks reactive connection. ```ts const state = reactive({ count: 0, name: 'Vue' }) // WRONG - destructured variables lose reactivity const { count, name } = state state.count++ console.log(count) // Still 0! // CORRECT - use toRefs() const { count, name } = toRefs(state) state.count++ console.log(count.value) // 1 // BEST - just use ref() instead of reactive() const count = ref(0) const name = ref('Vue') ``` ### Proxy Identity Hazard with reactive() ```ts const raw = {} const proxy = reactive(raw) // WRONG - comparing different objects console.log(proxy === raw) // false // WRONG - creating multiple proxies const a = reactive({}) const b = reactive(a) // Returns same proxy console.log(a === b) // true (same object) // GOTCHA - nested objects get proxied too const nested = reactive({ obj: {} }) console.log(nested.obj === nested.obj) // true (same proxy) ``` ## Computed Properties ### No Side Effects in Computed Getters **Impact: HIGH** - Side effects break reactivity model. ```ts // WRONG - mutates state const doubled = computed(() => { count.value++ // Side effect! return count.value * 2 }) // WRONG - async operation const data = computed(async () => { return await fetch('/api') // Side effect! }) // CORRECT - pure computation only const doubled = computed(() => count.value * 2) // For side effects, use watch: watch(count, (newVal) => { document.title = `Count: ${newVal}` }) ``` ### Computed Returns Are Read-Only ```ts const fullName = computed(() => `${first.value} ${last.value}`) // WRONG - computed values are read-only fullName.value = 'John Doe' // Error! // CORRECT - use writable computed const fullName = computed({ get: () => `${first.value} ${last.value}`, set: (val) => { const [f, l] = val.split(' ') first.value = f last.value = l } }) ``` ## Watchers ### Clean Up Async Operations to Prevent Race Conditions **Impact: HIGH** - Stale requests can overwrite newer data. ```ts const query = ref('') const results = ref([]) // WRONG - race condition watch(query, async (q) => { const res = await fetch(`/api?q=${q}`) results.value = await res.json() // May overwrite newer results! }) // CORRECT - use onWatcherCleanup (Vue 3.5+) watch(query, async (q) => { const controller = new AbortController() onWatcherCleanup(() => controller.abort()) try { const res = await fetch(`/api?q=${q}`, { signal: controller.signal }) results.value = await res.json() } catch (e) { if (e.name !== 'AbortError') throw e } }) // Or use onCleanup parameter watch(query, async (q, oldQ, onCleanup) => { const controller = new AbortController() onCleanup(() => controller.abort()) // ... same as above }) ``` ### Deep Watch Returns Same Object Reference ```ts const obj = reactive({ nested: { count: 0 } }) // GOTCHA - oldValue === newValue for deep watches watch(obj, (newVal, oldVal) => { console.log(newVal === oldVal) // true! Same object }, { deep: true }) // If you need old value, clone first: watch( () => structuredClone(obj), (newVal, oldVal) => { /* now different */ } ) ``` ## Props ### Props Are Read-Only - Never Mutate **Impact: HIGH** - Breaks one-way data flow. ```ts const props = defineProps<{ count: number; user: User }>() // WRONG - direct mutation props.count++ // Vue warning props.user.name = 'New' // No warning but still wrong! // CORRECT - emit to parent const emit = defineEmits(['update:count', 'update-user']) emit('update:count', props.count + 1) emit('update-user', { ...props.user, name: 'New' }) // Or create local copy const localUser = ref({ ...props.user }) ``` ### Destructured Props Don't Update Watchers (pre-3.5) ```ts // WRONG (Vue < 3.5) const { count } = defineProps<{ count: number }>() watch(count, () => {}) // Won't trigger! // CORRECT - use getter const props = defineProps<{ count: number }>() watch(() => props.count, () => {}) // Vue 3.5+ - destructuring works with reactive props const { count } = defineProps<{ count: number }>() watch(() => count, () => {}) // Works in 3.5+ ``` ## Lifecycle Hooks ### Register Hooks Synchronously During Setup **Impact: HIGH** - Async hooks silently fail. ```ts // WRONG - hook registered after await async setup() { const data = await fetchData() onMounted(() => {}) // Will NEVER run! } // WRONG - hook in setTimeout setup() { setTimeout(() => { onMounted(() => {}) // Will NEVER run! }, 100) } // CORRECT - register synchronously, async inside setup() { onMounted(async () => { const data = await fetchData() }) } ``` ## Templates ### Never Use v-if with v-for on Same Element **Impact: HIGH** - Vue 2/3 precedence differs. ```vue