Vue.js interviews test component design, reactivity system understanding, state management, and Composition API fluency. This guide covers the 50 most common Vue 3 questions — with concise answers and runnable code examples.
Quick reference
| Topic | Most asked questions |
|---|---|
| Fundamentals | Options vs Composition API, reactivity, SFC |
| Reactivity | ref vs reactive, computed, watch |
| Components | props, emits, slots, provide/inject |
| Vue Router | navigation guards, dynamic routes, lazy loading |
| State management | Pinia, Vuex vs Pinia |
| Performance | v-memo, KeepAlive, async components |
Fundamentals
1. What is the difference between Options API and Composition API?
| Feature | Options API | Composition API |
|---|---|---|
| Organisation | By option type (data/methods/computed) | By feature/concern |
| Code reuse | Mixins (namespace collisions) | Composables (explicit) |
| TypeScript | Awkward (this typing) |
Excellent (plain functions) |
| Introduced | Vue 2 | Vue 3 (also backported to Vue 2.7) |
| Verbosity | More boilerplate | Less, but more flexible |
<!-- Options API -->
<script>
export default {
data() { return { count: 0 } },
computed: { double() { return this.count * 2 } },
methods: { increment() { this.count++ } }
}
</script>
<!-- Composition API -->
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
const increment = () => count.value++
</script>
Both APIs are fully supported in Vue 3. Composition API is recommended for new projects.
2. What is a Single File Component (SFC)?
An SFC is a .vue file that encapsulates template, script, and style in one file.
<template>
<button @click="count++">{{ count }}</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<style scoped>
button { padding: 8px 16px; }
</style>
<style scoped> adds a unique data attribute so styles only apply to the current component.
3. What is Vue's reactivity system?
Vue 3 uses Proxy-based reactivity (replacing Vue 2's Object.defineProperty). When you access or mutate reactive data, Vue tracks dependencies via a dependency tracking system (effect tracking).
import { reactive } from 'vue'
const state = reactive({ count: 0 })
// Accessing state.count inside a computed/watchEffect registers a dependency
// Mutating state.count triggers re-renders of all dependents
Benefits over Vue 2:
- Detects property additions/deletions (no
Vue.setneeded) - Works with Arrays natively
- Supports
Map,Set,WeakMap,WeakSet
4. What is the difference between ref and reactive?
ref |
reactive |
|
|---|---|---|
| Wraps | Any value (primitives + objects) | Objects only |
| Access in JS | .value required |
Direct property access |
| Access in template | Auto-unwrapped (no .value) |
Direct |
| Destructuring | Safe (keep reactivity) | Loses reactivity (use toRefs) |
| Reassignment | ref.value = newObj works |
Must mutate properties |
import { ref, reactive, toRefs } from 'vue'
// ref — primitives need .value
const count = ref(0)
count.value++
// reactive — objects, no .value
const state = reactive({ count: 0, name: 'Vue' })
state.count++
// Destructuring reactive loses reactivity — use toRefs
const { count: countRef } = toRefs(state)
Rule of thumb: Use ref for primitives, either for objects. Many teams use ref exclusively for consistency.
5. What is computed and how does it differ from a method?
computed is cached based on its reactive dependencies. A method runs every time it's called.
<script setup>
import { ref, computed } from 'vue'
const items = ref([1, 2, 3, 4, 5])
// Cached — only recomputes when `items` changes
const evenItems = computed(() => items.value.filter(n => n % 2 === 0))
// Runs every render call, even if items hasn't changed
function getEvenItems() {
return items.value.filter(n => n % 2 === 0)
}
</script>
Use computed for derived state, methods for event handlers and imperative actions.
6. How does watch differ from watchEffect?
watch |
watchEffect |
|
|---|---|---|
| Source | Explicit (ref, reactive, getter) | Auto-tracked (runs immediately) |
| Old value | Provides (newVal, oldVal) |
Not available |
| Lazy by default | Yes (unless { immediate: true }) |
No — runs on mount |
| Use case | React to specific source changes | Side effects that depend on reactive state |
import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
// watch — explicit, lazy
watch(count, (newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`)
})
// watchEffect — auto-tracks, eager
watchEffect(() => {
console.log('count is', count.value) // runs immediately
})
7. What is defineProps and defineEmits in <script setup>?
Compiler macros that declare a component's public interface without imports.
<script setup>
// Props with types and defaults
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 }
})
// TypeScript alternative
// const props = defineProps<{ title: string; count?: number }>()
// const { count = 0 } = withDefaults(defineProps<...>(), { count: 0 })
// Emits declaration
const emit = defineEmits(['update:count', 'close'])
// Usage
emit('update:count', props.count + 1)
</script>
8. How does two-way binding work with v-model on components?
v-model on a component expands to a prop + emit pair.
<!-- Parent -->
<MyInput v-model="name" />
<!-- Same as: <MyInput :modelValue="name" @update:modelValue="name = $event" /> -->
<!-- MyInput.vue -->
<script setup>
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input :value="modelValue" @input="emit('update:modelValue', $event.target.value)" />
</template>
Vue 3.4+ introduces defineModel() — a shorthand that handles this automatically:
<script setup>
const model = defineModel() // creates modelValue prop + update:modelValue emit
</script>
<template>
<input v-model="model" />
</template>
9. What are slots? Explain named and scoped slots.
Slots let a parent inject content into a child's template.
<!-- Child: Card.vue -->
<template>
<div class="card">
<header><slot name="header" /></header>
<main><slot /></main> <!-- default slot -->
<footer>
<!-- Scoped slot: exposes data to parent -->
<slot name="footer" :stats="{ views: 42 }" />
</footer>
</div>
</template>
<!-- Parent usage -->
<Card>
<template #header>My Title</template>
<p>Card body content</p>
<template #footer="{ stats }">
<span>Views: {{ stats.views }}</span>
</template>
</Card>
10. What is provide / inject and when should you use it?
provide/inject passes data from an ancestor to any descendant — avoiding prop drilling.
<!-- Ancestor -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme) // provide reactive value
</script>
<!-- Deep descendant -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light') // 'light' is default
</script>
Use for cross-cutting concerns (theme, locale, auth) shared across a subtree. For global app state, prefer Pinia.
Reactivity & Composition
11. What are composables and how do you write one?
A composable is a function that uses Vue's Composition API to encapsulate reusable stateful logic.
// composables/useFetch.js
import { ref, watchEffect } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
watchEffect(async () => {
loading.value = true
error.value = null
try {
const res = await fetch(url.value ?? url)
data.value = await res.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
return { data, error, loading }
}
// Component usage
const { data, error, loading } = useFetch('https://api.example.com/users')
Naming convention: use prefix (same as React hooks).
12. What is toRefs and why is it needed?
toRefs converts a reactive object's properties into individual refs — preserving reactivity after destructuring.
import { reactive, toRefs } from 'vue'
const state = reactive({ x: 0, y: 0 })
// WRONG — loses reactivity
const { x, y } = state
// CORRECT — each is a ref
const { x, y } = toRefs(state)
// x.value / y.value are reactive
Commonly used in composables that return reactive state.
13. How does shallowRef / shallowReactive differ from ref / reactive?
| Deep version | Shallow version | |
|---|---|---|
| Reactivity depth | All nested properties | Root level only |
| Performance | More overhead | Better for large objects |
| Use case | Most cases | Large read-only datasets, external libs |
import { shallowRef } from 'vue'
const state = shallowRef({ nested: { count: 0 } })
// This WON'T trigger reactivity
state.value.nested.count++
// This WILL trigger reactivity (replaces root)
state.value = { nested: { count: 1 } }
14. What is markRaw and when do you use it?
markRaw marks an object so Vue's reactivity system skips it. Useful for third-party library instances (e.g., Chart.js, Three.js) that must not be proxied.
import { reactive, markRaw } from 'vue'
const chart = markRaw(new Chart(canvas, config))
const state = reactive({
chart, // Not converted to a Proxy — Chart.js methods work correctly
label: 'Sales'
})
15. Explain the component lifecycle in Vue 3.
setup() ← Composition API entry point (replaces beforeCreate + created)
↓
onBeforeMount ← DOM not yet created
↓
onMounted ← DOM is ready, refs accessible
↓
onBeforeUpdate ← Reactive data changed, DOM not yet updated
↓
onUpdated ← DOM re-rendered
↓
onBeforeUnmount ← Component about to be destroyed
↓
onUnmounted ← Cleanup (timers, subscriptions, event listeners)
<script setup>
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>
Templates & Directives
16. What is the difference between v-if and v-show?
v-if |
v-show |
|
|---|---|---|
| Renders to DOM | Only when true |
Always |
| Toggle cost | High (create/destroy) | Low (CSS display) |
| Initial render cost | Low when false |
Higher |
| Use when | Condition rarely changes | Toggling frequently |
<div v-if="isLoggedIn">Dashboard</div> <!-- removed from DOM when false -->
<div v-show="isMenuOpen">Menu</div> <!-- display:none when false -->
17. How does v-for work and why are keys important?
v-for renders a list from an array, object, or range.
<ul>
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
</ul>
Keys give Vue a stable identity for each node. Without unique keys:
- Vue may reuse wrong DOM nodes
- Component state gets mixed up
- Animations break
Never use the array index as a key when the list can be reordered or filtered.
18. What is v-bind shorthand and what can it bind to?
v-bind:attr can be shortened to :attr. It binds JS expressions to HTML attributes, DOM properties, component props, and event listeners.
<img :src="imageUrl" :alt="imageAlt" />
<button :disabled="isLoading">Submit</button>
<MyComponent :user="currentUser" />
<!-- Bind entire object of props at once -->
<MyComponent v-bind="userProps" />
19. What event modifiers does Vue provide?
Event modifiers are chained with . after the event name.
<!-- Stop propagation -->
<button @click.stop="handleClick">Click</button>
<!-- Prevent default -->
<form @submit.prevent="submitForm">...</form>
<!-- Only trigger once -->
<button @click.once="trackFirstClick">Track</button>
<!-- Only trigger from element itself, not children -->
<div @click.self="handleBackdropClick">
<Modal />
</div>
<!-- Key modifiers -->
<input @keyup.enter="search" @keyup.esc="clearSearch" />
20. What is v-memo and when should you use it?
v-memo memoises a subtree of the template — it skips re-rendering when the dependency array hasn't changed.
<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
<!-- Only re-renders when item.selected changes -->
<HeavyComponent :item="item" />
</div>
Use for large lists where most items rarely change. Don't use prematurely — measure first.
Vue Router
21. How do you define routes with Vue Router 4?
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('./views/Home.vue') }, // lazy load
{ path: '/users/:id', component: () => import('./views/User.vue') },
{ path: '/admin', component: AdminLayout,
children: [
{ path: '', component: AdminDashboard },
{ path: 'settings', component: AdminSettings }
]
},
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }
]
})
22. How do you access route params and query strings?
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
// /users/42?tab=profile
console.log(route.params.id) // '42'
console.log(route.query.tab) // 'profile'
console.log(route.name) // named route
console.log(route.fullPath) // '/users/42?tab=profile'
</script>
23. What are navigation guards and when do you use them?
Navigation guards intercept route changes to redirect, cancel, or add side effects.
// Global guard
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})
// Per-route guard
{
path: '/admin',
component: Admin,
beforeEnter: (to, from) => {
if (!isAdmin()) return '/403'
}
}
<!-- In-component guard -->
<script setup>
import { onBeforeRouteLeave } from 'vue-router'
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
return confirm('Leave without saving?')
}
})
</script>
24. How do you programmatically navigate with Vue Router?
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
function goToUser(id) {
router.push({ name: 'User', params: { id } })
}
function goBack() {
router.back()
}
function replaceRoute() {
// Replaces current history entry (no back button)
router.replace('/dashboard')
}
</script>
25. What is <RouterView> and <RouterLink>?
<RouterView>is a placeholder that renders the matched route's component.<RouterLink>renders an<a>tag that navigates without page reload and automatically addsactive-classwhen the route matches.
<!-- App.vue -->
<nav>
<RouterLink to="/" exact-active-class="active">Home</RouterLink>
<RouterLink :to="{ name: 'User', params: { id: 1 } }">Profile</RouterLink>
</nav>
<RouterView />
State Management (Pinia)
26. What is Pinia and how does it differ from Vuex?
| Feature | Pinia | Vuex 4 |
|---|---|---|
| API | Composition API style | Options-style (state/getters/mutations/actions) |
| Mutations | Removed — directly mutate state | Required for state changes |
| TypeScript | Excellent (inferred types) | Verbose, needs type casting |
| Devtools | Full support | Full support |
| Bundle size | ~1KB | Larger |
| Modules | Flat stores (no namespacing needed) | Nested modules with namespacing |
Pinia is the official recommended state manager for Vue 3.
27. How do you create and use a Pinia store?
// stores/useCounterStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// State
const count = ref(0)
// Getters
const double = computed(() => count.value * 2)
// Actions
function increment() { count.value++ }
async function fetchAndSet(url) {
const res = await fetch(url)
count.value = (await res.json()).count
}
return { count, double, increment, fetchAndSet }
})
<!-- Component usage -->
<script setup>
import { useCounterStore } from '@/stores/useCounterStore'
import { storeToRefs } from 'pinia'
const store = useCounterStore()
// storeToRefs preserves reactivity when destructuring
const { count, double } = storeToRefs(store)
</script>
<template>
<p>{{ count }} × 2 = {{ double }}</p>
<button @click="store.increment()">+</button>
</template>
28. Why must you use storeToRefs when destructuring Pinia stores?
Destructuring a store directly breaks reactivity for state and getters (actions are fine to destructure).
const store = useCounterStore()
// WRONG — count / double are plain values, not reactive
const { count, double, increment } = store
// CORRECT — count and double are refs
const { count, double } = storeToRefs(store)
const { increment } = store // actions don't need storeToRefs
29. How do you persist Pinia state across page refreshes?
Use the pinia-plugin-persistedstate plugin.
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// store
export const useUserStore = defineStore('user', () => {
const token = ref(null)
return { token }
}, {
persist: true // saves to localStorage by default
})
30. How do you reset a Pinia store?
With the Setup Store API, implement a $reset manually (it's built-in only for Options Stores):
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('')
function $reset() {
count.value = 0
name.value = ''
}
return { count, name, $reset }
})
Performance
31. What is <KeepAlive> and when do you use it?
<KeepAlive> caches component instances — preventing destroy/recreate cycles when toggling.
<KeepAlive :include="['TabA', 'TabB']" :max="5">
<component :is="currentTab" />
</KeepAlive>
The cached component receives onActivated / onDeactivated lifecycle hooks instead of onMounted / onUnmounted.
Use for: tabs, wizards, heavy components where remounting is expensive.
32. How do you lazily load components in Vue?
// Async component (Vue 3)
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: Spinner,
errorComponent: ErrorDisplay,
delay: 200, // show loading after 200ms
timeout: 10000
})
In Vue Router, all route components should be lazy-loaded:
{ path: '/dashboard', component: () => import('./views/Dashboard.vue') }
33. What is Suspense in Vue 3?
<Suspense> renders a fallback while async setup components or async components are loading.
<Suspense>
<template #default>
<AsyncUserProfile /> <!-- component with async setup() -->
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
<!-- AsyncUserProfile.vue -->
<script setup>
// Async setup is allowed
const user = await fetchUser(userId)
</script>
34. How does Vue handle list rendering performance?
- Use stable, unique keys (
item.id, not array index) v-memoto skip subtree re-rendering when dependencies unchanged- Virtual scrolling (e.g.,
vue-virtual-scroller) for lists > 1000 items shallowReffor large datasets where deep reactivity is unnecessaryObject.freezefor completely static data
35. What is Transition and TransitionGroup?
<Transition> animates a single element entering/leaving.<TransitionGroup> animates list items (supports move animations via FLIP).
<Transition name="fade">
<p v-if="show">Hello</p>
</Transition>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
Available CSS classes: {name}-enter-from, {name}-enter-active, {name}-enter-to, and leave equivalents.
Advanced Topics
36. How do you create a custom directive in Vue 3?
// Directive object
const vFocus = {
mounted(el) { el.focus() }
}
// Globally registered
app.directive('focus', {
mounted(el, binding) {
if (binding.value !== false) el.focus()
}
})
// Usage
<input v-focus />
<input v-focus="shouldFocus" />
Directive hooks: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted.
37. What is Teleport and when do you need it?
<Teleport> renders a component's template in a different part of the DOM — useful for modals and tooltips that need to escape CSS stacking contexts.
<button @click="open = true">Open Modal</button>
<Teleport to="body">
<div v-if="open" class="modal-overlay">
<div class="modal">
<p>Modal content</p>
<button @click="open = false">Close</button>
</div>
</div>
</Teleport>
The component stays in the Vue component tree (props/events work normally) but renders to body.
38. How does Vue handle TypeScript?
Vue 3 is written in TypeScript and has first-class TS support.
<script setup lang="ts">
import { ref } from 'vue'
interface User {
id: number
name: string
}
// Type-safe props
const props = defineProps<{
title: string
user?: User
}>()
// withDefaults for default values
const { title, user = { id: 0, name: 'Guest' } } = withDefaults(
defineProps<{ title: string; user?: User }>(),
{ user: () => ({ id: 0, name: 'Guest' }) }
)
const count = ref<number>(0)
const emit = defineEmits<{
update: [value: number] // typed named tuple
close: []
}>()
</script>
39. What is the difference between defineExpose and normal <script setup>?
In <script setup>, all bindings are private by default. defineExpose selectively exposes bindings to parent refs.
<!-- ChildInput.vue -->
<script setup>
import { ref } from 'vue'
const inputRef = ref(null)
const value = ref('')
defineExpose({ focus: () => inputRef.value?.focus(), value })
</script>
<!-- Parent -->
<script setup>
const childRef = ref(null)
function focusChild() {
childRef.value?.focus() // calls exposed method
}
</script>
<template>
<ChildInput ref="childRef" />
</template>
40. How do Vue 3 and Vue 2 differ in breaking ways?
| Change | Vue 2 | Vue 3 |
|---|---|---|
| Global API | Vue.component(), Vue.use() |
app.component(), app.use() |
v-model |
:value + @input |
:modelValue + @update:modelValue |
| Fragments | Single root element required | Multiple root elements allowed |
| Filters | {{ date | format }} |
Removed (use methods/computed) |
$children |
Available | Removed |
| Reactivity | Object.defineProperty |
Proxy |
$listeners |
Separate from $attrs |
Merged into $attrs |
| Transition classes | v-enter |
v-enter-from |
Testing
41. How do you test Vue components with Vue Test Utils?
import { mount, shallowMount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Counter from './Counter.vue'
describe('Counter', () => {
it('increments on click', async () => {
const wrapper = mount(Counter, {
props: { initialCount: 5 }
})
expect(wrapper.text()).toContain('5')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('6')
})
it('emits update event', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('update')).toBeTruthy()
expect(wrapper.emitted('update')[0]).toEqual([1])
})
})
42. How do you test Pinia stores?
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, it, expect } from 'vitest'
import { useCounterStore } from './useCounterStore'
describe('Counter Store', () => {
beforeEach(() => setActivePinia(createPinia()))
it('increments', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
store.increment()
expect(store.count).toBe(1)
expect(store.double).toBe(2)
})
})
43. What is shallowMount vs mount?
mount |
shallowMount |
|
|---|---|---|
| Child components | Rendered fully | Stubbed out |
| Speed | Slower | Faster |
| Use case | Integration tests, real interactions | Unit testing component in isolation |
// shallowMount stubs <ChildComponent> with <child-component-stub>
const wrapper = shallowMount(Parent)
expect(wrapper.findComponent({ name: 'ChildComponent' }).exists()).toBe(true)
Common Pitfalls
44. Why is mutating props directly a bad practice?
Vue enforces one-way data flow. Mutating a prop:
- Creates unpredictable state (parent and child out of sync)
- Generates a Vue warning
- Breaks the component contract
Correct patterns:
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
// WRONG: props.modelValue = newVal
// CORRECT: emit
function handleInput(e) {
emit('update:modelValue', e.target.value)
}
// Or use a writable computed
const localValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
</script>
45. What causes "Maximum recursive updates exceeded" in Vue?
Triggered when a watch or computed causes a state change that re-triggers itself.
// BAD — infinite loop
watch(count, (val) => {
count.value = val + 1 // triggers watch again
})
// BAD — computed with side effect
const bad = computed(() => {
otherRef.value++ // side effect in computed!
return count.value * 2
})
Fix: avoid side effects in computed, ensure watch handlers don't re-trigger their own source.
46. Why doesn't v-for work with v-if on the same element?
In Vue 3, v-if has higher priority than v-for when on the same element — so v-if can't access the v-for variable.
<!-- WRONG — `item` is not in scope for v-if -->
<li v-for="item in items" v-if="item.active">{{ item.name }}</li>
<!-- CORRECT — wrap with template -->
<template v-for="item in items" :key="item.id">
<li v-if="item.active">{{ item.name }}</li>
</template>
<!-- BETTER — filter before rendering -->
<li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
47. What happens if you lose reactivity in a reactive object?
// LOST — reassigning breaks reactivity
let state = reactive({ count: 0 })
state = reactive({ count: 1 }) // the old state object is no longer tracked
// LOST — destructuring
const { count } = state // plain number
// SOLUTION: use ref instead of reactive for root reassignment
const state = ref({ count: 0 })
state.value = { count: 1 } // works
// SOLUTION: for destructuring, use toRefs
const { count } = toRefs(state)
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
Using array index as :key |
Wrong elements reused on sort/filter | Use stable unique IDs |
| Mutating props directly | One-way flow broken, Vue warning | Emit events to parent |
Watching deep objects without { deep: true } |
Nested changes not detected | Add { deep: true } or use watchEffect |
Accessing .value in template |
Auto-unwrapped in templates | Remove .value in template |
Async setup without <Suspense> |
Top-level await silently fails | Wrap async component in <Suspense> |
Reactive state in v-for without :key |
Wrong DOM reuse | Always add :key |
| Blocking computed with async | Computed must be synchronous | Use watchEffect + ref for async derived state |
Not cleaning up in onUnmounted |
Memory leaks (timers, subscriptions) | Use onUnmounted or useEventListener composable |
Vue vs React vs Angular
| Feature | Vue 3 | React 18 | Angular 17 |
|---|---|---|---|
| Learning curve | Low | Medium | High |
| Reactivity | Proxy (built-in) | Manual (useState/useEffect) | Signals / Zone.js |
| Template | HTML-like templates | JSX | HTML templates |
| State management | Pinia (official) | External (Redux/Zustand) | NgRx / Signals |
| TypeScript | Excellent | Excellent | Excellent (built-in) |
| Bundle size | ~34KB (runtime) | ~6KB (react+dom) | ~180KB+ |
| Two-way binding | v-model |
Manual | [(ngModel)] |
| Official router | Vue Router | React Router (unofficial) | Built-in |
FAQ
Q: Should I use Options API or Composition API for a new Vue 3 project?
A: Use Composition API with <script setup>. It offers better TypeScript support, more flexible code organisation, and composables for reuse. Options API is still fully supported and valid for simpler components.
Q: What is the difference between ref and reactive — which should I always use?
A: ref works for any type, requires .value in JS but not in templates. reactive is for objects only, no .value needed but can't be reassigned. Many teams use ref exclusively for consistency. Either is fine.
Q: When should I use Pinia vs local component state?
A: Use local state (ref/reactive) for data that belongs only to a component or its subtree. Use Pinia when multiple unrelated components need the same data, when you need to persist state, or when debugging complex flows with devtools.
Q: Is $store gone in Vue 3?
A: Vuex 4 (with $store) still works in Vue 3 but Pinia is the recommended replacement. Pinia has no $store property — you import and call the store composable directly.
Q: How do I handle global error handling in Vue?
A: Use app.config.errorHandler:
app.config.errorHandler = (err, instance, info) => {
// log to Sentry / your error tracker
console.error(err, info)
}
Q: How do I migrate a Vue 2 project to Vue 3?
A: Use the official Vue Migration Build (@vue/compat) for a incremental migration. Main steps: upgrade Vue, migrate global API (Vue.component → app.component), update v-model, replace filters with methods, handle composition API vs Options API per component. Use the vue-router v4 and Pinia (replacing Vuex) along the way.