Vue.js Interview Questions and How to Answer Them (42 Questions)
This guide covers 42 questions across every layer of the Vue.js stack — from core concepts to advanced architecture decisions. Each answer explains the *why*, shows real code, and points out what interviewers are actually listening for. Questions are ordered from foundational to senior-level so you can stop at the right tier for your target role.
Core Concepts (Questions 1–10)
1. What is Vue.js and what problems does it solve?
Vue.js is a progressive JavaScript framework for building user interfaces. "Progressive" means you can adopt it incrementally: sprinkle it into a server-rendered page, or build an entire SPA with it.
It solves three main problems:
- Declarative rendering — you describe what the UI should look like for a given state, and Vue updates the DOM automatically.
- Component-based architecture — UI is split into self-contained, reusable pieces.
- Reactive data binding — the UI stays in sync with application state without manual DOM manipulation.
What interviewers listen for: a concise definition followed by a real trade-off. Vue sits between React (maximum flexibility, you own everything) and Angular (opinionated, batteries-included). Vue ships a router and state library, but they're optional.
2. What is a Single-File Component (SFC)?
An SFC is a .vue file that collocates the component's template, logic, and styles in one place:
<template>
<button @click="count++">Clicked {{ count }} times</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<style scoped>
button { padding: 8px 16px; }
</style>Benefits: co-location improves readability; scoped prevents style leakage; build tools (Vite, webpack) handle transforms. The syntax is the modern standard — it compiles to the Composition API and reduces boilerplate.
3. What is the difference between the Options API and the Composition API?
| | Options API | Composition API |
|---|---|---|
| Structure | Object with named sections (data, methods, computed) | Functions inside setup() or |
| Logic reuse | Mixins (conflict-prone) | Composables (explicit, tree-shakeable) |
| TypeScript | Awkward | First-class |
| Readability | Great for small/simple components | Better for complex, multi-concern components |
Options API example:
<script>
export default {
data() {
return { count: 0 }
},
computed: {
doubled() { return this.count * 2 }
},
methods: {
increment() { this.count++ }
}
}
</script>Composition API equivalent:
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
</script>The Options API is not deprecated — it is still valid and perfectly fine for simple components. The Composition API scales better when a component handles multiple concerns, because you group logic by feature rather than by option type.
4. How does Vue's reactivity system work?
In Vue 3, reactivity is built on JavaScript Proxy. When you call reactive(obj), Vue wraps obj in a Proxy that intercepts get and set operations. During rendering (or inside watchEffect/computed), Vue tracks which reactive properties were read. When any of those properties change, Vue knows exactly which effects to re-run.
import { reactive, watchEffect } from 'vue'
const state = reactive({ count: 0 })
watchEffect(() => {
console.log('count is:', state.count) // runs immediately, then re-runs on change
})
state.count++ // logs "count is: 1"ref() wraps a single value in an object with a .value property so the Proxy can intercept access:
import { ref } from 'vue'
const count = ref(0)
count.value++ // triggers reactivity
// In templates, .value is unwrapped automatically: {{ count }}Vue 2 vs Vue 3: Vue 2 used Object.defineProperty, which could not detect property additions or array index mutations — you had to use Vue.set(). Vue 3's Proxy handles these cases natively.
5. What is the difference between `ref` and `reactive`?
ref is for a single value (primitive or object); you always access it via .value.
reactive is for objects; properties are accessed directly — but the object reference itself is not reactive.
import { ref, reactive, toRefs } from 'vue'
// ref
const name = ref('Alice')
name.value = 'Bob' // correct
// reactive
const user = reactive({ name: 'Alice', age: 30 })
user.name = 'Bob' // correct
// DANGER: destructuring loses reactivity
const { name: userName } = user // userName is no longer reactive
// FIX: use toRefs
const { name: userName } = toRefs(user) // userName.value is reactivePractical rule: use ref for primitives and when you need to replace the whole value. Use reactive for objects where you only mutate properties. When in doubt, ref works everywhere.
6. What is the difference between `computed` and `watch`?
Computed — derives a new value from reactive state. It is cached: if its dependencies have not changed, reading it returns the cached result without re-running the function. Use it whenever you need to transform or combine reactive data.
import { ref, computed } from 'vue'
const items = ref([
{ name: 'Apple', price: 1.5 },
{ name: 'Banana', price: 0.75 }
])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
)
// total.value === 2.25
// Re-evaluates only when items changesWatch — runs a side effect when a specific reactive source changes. It does *not* return a value. Use it for async work, calling APIs, writing to localStorage, or logging.
import { ref, watch } from 'vue'
const userId = ref(1)
watch(userId, async (newId) => {
const res = await fetch(`/api/users/${newId}`)
user.value = await res.json()
})Mnemonic: computed = derive a value (synchronous, cached). watch = react to a change (can be async, triggers side effects).
7. What is `watchEffect` and how does it differ from `watch`?
watchEffect runs a function immediately and automatically tracks every reactive value read inside it. When any of those values change, it re-runs.
import { ref, watchEffect } from 'vue'
const searchQuery = ref('')
const results = ref([])
watchEffect(async () => {
// automatically tracks searchQuery.value
const res = await fetch(`/api/search?q=${searchQuery.value}`)
results.value = await res.json()
})watch requires you to declare the source(s) explicitly, gives you both old and new values, and does not run immediately by default (use { immediate: true } to override).
Use watchEffect for "whenever any of these dependencies change, do this." Use watch when you need the previous value, conditional watching, or want explicit control over which source triggers the effect.
8. What directives does Vue provide, and when would you create a custom one?
Built-in directives handle the most common DOM concerns:
| Directive | Purpose |
|---|---|
| v-bind / : | Bind attribute or prop dynamically |
| v-on / @ | Listen to DOM events |
| v-model | Two-way binding for form inputs |
| v-if / v-else-if / v-else | Conditional rendering (removes/inserts DOM) |
| v-show | Conditional visibility (CSS only, stays in DOM) |
| v-for | List rendering |
| v-once | Render once, skip future updates |
| v-memo | Memoize a subtree based on dependencies |
| v-pre | Skip compilation for this element |
You create a custom directive when you need direct, low-level DOM access that does not fit naturally into component logic:
// A directive that auto-focuses an input when it mounts
const vFocus = {
mounted(el) {
el.focus()
}
}
// Usage in <script setup>:
// const vFocus = { mounted: (el) => el.focus() }<template>
<input v-focus />
</template>Another real-world example — an outside-click directive:
const vClickOutside = {
mounted(el, binding) {
el._clickOutsideHandler = (event) => {
if (!el.contains(event.target)) {
binding.value(event)
}
}
document.addEventListener('click', el._clickOutsideHandler)
},
unmounted(el) {
document.removeEventListener('click', el._clickOutsideHandler)
}
}9. What is the difference between `v-if` and `v-show`?
v-if removes and re-creates the element and its subtree in the DOM every time the condition changes. This has higher toggle cost but lower initial render cost when the condition starts false.
v-show always renders the element but toggles display: none via CSS. This has lower toggle cost (just a style change) but always pays the initial render cost.
<!-- Use v-if for rare conditions (auth-gated sections, error states) -->
<AdminPanel v-if="isAdmin" />
<!-- Use v-show for frequently toggled UI (dropdowns, tabs, accordions) -->
<Dropdown v-show="isOpen" />Interviewer trap: "v-show cannot be used with or v-else." If you know this, you demonstrate real-world experience.
10. Why do keys matter in `v-for`?
Keys give Vue a stable identity for each item in a list. Without a key (or with index as key), Vue reuses DOM nodes by position. When list order changes, Vue may patch the wrong nodes — causing bugs in form inputs, animations, and focused elements.
<!-- Bad: index as key breaks on sort/delete -->
<li v-for="(item, index) in items" :key="index">{{ item.name }}</li>
<!-- Good: stable unique ID -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>When you delete item at index 2, Vue without stable keys will try to patch position 2's DOM node rather than remove the right element. With stable IDs, Vue knows exactly which DOM node maps to which data.
Component Communication (Questions 11–18)
11. How do props work? What are prop validation best practices?
Props are the mechanism for passing data from a parent component to a child. A child should treat props as read-only — it should never mutate them directly.
<!-- Parent -->
<UserCard :user="currentUser" :show-actions="true" />
<!-- Child (UserCard.vue) -->
<script setup>
const props = defineProps({
user: {
type: Object,
required: true,
validator: (v) => v.id && v.name
},
showActions: {
type: Boolean,
default: false
}
})
</script>With TypeScript:
<script setup lang="ts">
interface User { id: number; name: string; email: string }
const props = defineProps<{
user: User
showActions?: boolean
}>()
</script>Best practices: always declare types; use required for mandatory props; provide default for optional ones; use validator for constrained strings like status codes.
12. How do custom events and `$emit` work?
A child communicates back to its parent by emitting events. The parent listens with v-on (shorthand @).
<!-- Child (ConfirmDialog.vue) -->
<script setup>
const emit = defineEmits(['confirm', 'cancel'])
function handleConfirm() {
emit('confirm', { timestamp: Date.now() })
}
</script>
<template>
<div>
<button @click="handleConfirm">Confirm</button>
<button @click="emit('cancel')">Cancel</button>
</div>
</template><!-- Parent -->
<ConfirmDialog
@confirm="handleConfirm"
@cancel="isOpen = false"
/>Rule: the parent owns state. The child reports user intent. Never mutate a prop — emit an event and let the parent decide.
13. How does `v-model` work on components? What is `defineModel`?
v-model on a component is syntactic sugar for a prop + an event. By default it expands to :modelValue="x" @update:modelValue="x = $event".
Pre-Vue 3.4 pattern:
<!-- Child -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input :value="modelValue" @input="emit('update:modelValue', $event.target.value)" />
</template>Vue 3.4+ with defineModel:
<script setup>
const model = defineModel()
</script>
<template>
<input v-model="model" />
</template>You can also name it for multiple bindings:
<!-- Parent -->
<SearchBar v-model:query="query" v-model:filters="filters" />14. What are slots, named slots, and scoped slots?
Default slot: pass arbitrary template content into a child.
<!-- Card.vue -->
<template>
<div class="card">
<slot />
</div>
</template>
<!-- Usage -->
<Card><p>Some content</p></Card>Named slots: multiple content areas.
<!-- Layout.vue -->
<template>
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</template>
<!-- Usage -->
<Layout>
<template #header><h1>Title</h1></template>
Main content
<template #footer><p>Footer</p></template>
</Layout>Scoped slots: child exposes data to the parent's slot content — the inversion-of-control pattern.
<!-- DataTable.vue — child exposes each row to the parent -->
<template>
<table>
<tr v-for="row in rows" :key="row.id">
<slot :row="row" />
</tr>
</table>
</template>
<!-- Parent controls how each row renders -->
<DataTable :rows="users">
<template #default="{ row }">
<td>{{ row.name }}</td>
<td>{{ row.email }}</td>
</template>
</DataTable>15. What is provide/inject and when should you use it?
provide / inject passes values down the component tree without threading props through every intermediate component (prop drilling).
// Ancestor
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme) // provide a reactive ref so descendants get live updates// Any descendant, regardless of depth
import { inject } from 'vue'
const theme = inject('theme', 'light') // second arg is defaultUse it for genuinely cross-cutting concerns: theme, locale, user authentication context, form state shared with deeply nested field components.
Do not use it as a substitute for normal parent-child props — it hides the data flow and makes components harder to test and reuse in isolation.
16. What are composables and how do they replace mixins?
A composable is a function that encapsulates and returns reactive state and methods, using the Composition API internally.
// composables/useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => window.addEventListener('resize', update))
onUnmounted(() => window.removeEventListener('resize', update))
return { width, height }
}<script setup>
import { useWindowSize } from '@/composables/useWindowSize'
const { width, height } = useWindowSize()
</script>Why composables beat mixins:
- No namespace collisions — you name the return values yourself
- Explicit dependencies — no magic injection
- TypeScript-friendly — composable return types are fully typed
- Testable in isolation — just call the function in a test
17. How do you share state between sibling components that do not have a common parent?
Three patterns, in order of increasing scope:
- 1Lift state up — move state to the nearest common ancestor and pass down via props/emits. Best for co-located siblings.
- 2Provide/inject — if the common ancestor is far away and threading props is painful.
- 3Pinia store — when the state is truly global or shared across unrelated parts of the app.
// pinia store
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
function addItem(item) { items.value.push(item) }
function removeItem(id) { items.value = items.value.filter(i => i.id !== id) }
const total = computed(() => items.value.reduce((s, i) => s + i.price, 0))
return { items, addItem, removeItem, total }
})Both ProductPage and CartSidebar call useCartStore() independently and get the same reactive state.
18. How do Vue's lifecycle hooks map between Options API and Composition API?
| Stage | Options API | Composition API |
|---|---|---|
| Component created, before mount | created | (code in setup runs at this point) |
| After DOM inserted | mounted | onMounted |
| Before re-render | beforeUpdate | onBeforeUpdate |
| After re-render | updated | onUpdated |
| Before destroy | beforeUnmount | onBeforeUnmount |
| After destroy | unmounted | onUnmounted |
| Error from descendant | errorCaptured | onErrorCaptured |
<script setup>
import { onMounted, onUnmounted, onErrorCaptured } from 'vue'
onMounted(() => {
// Safe to access DOM, call APIs, set up third-party libs
})
onUnmounted(() => {
// Clean up: remove event listeners, cancel timers, disconnect observers
})
onErrorCaptured((err, instance, info) => {
// Log to error tracking service
return false // prevents further propagation
})
</script>What interviewers listen for: "I always pair setup work in onMounted with teardown in onUnmounted." This signals you think about memory leaks.
Vue Router (Questions 19–22)
19. How does Vue Router work and what are navigation guards?
Vue Router maps URL paths to component trees. It renders the matched component inside .
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/views/Home.vue') },
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true }
},
{ path: '/user/:id', component: () => import('@/views/UserProfile.vue') },
{ path: '/:pathMatch(.*)*', component: () => import('@/views/NotFound.vue') }
]
})
// Global guard — runs before every navigation
router.beforeEach((to, from) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})Guard types:
- Global (
beforeEach,beforeResolve,afterEach) — for auth, analytics, scroll behavior - Per-route (
beforeEnter) — for route-specific logic - In-component (
onBeforeRouteLeave,onBeforeRouteUpdate) — for unsaved-form warnings
20. How do you handle nested routes and route-level code splitting?
Nested routes let a parent layout render child views inside its own :
{
path: '/settings',
component: () => import('@/layouts/SettingsLayout.vue'),
children: [
{ path: '', redirect: 'profile' },
{ path: 'profile', component: () => import('@/views/settings/Profile.vue') },
{ path: 'billing', component: () => import('@/views/settings/Billing.vue') }
]
}The dynamic import () => import(...) tells the bundler to create a separate chunk for each route component. They load only when the user navigates to that route — this is route-level code splitting.
21. How do you access route params and query strings in a component?
<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
// URL: /user/42?tab=posts
console.log(route.params.id) // "42"
console.log(route.query.tab) // "posts"
</script>If you need to react when params change (e.g., navigating from /user/1 to /user/2 without a full component remount), use a watcher:
<script setup>
import { useRoute } from 'vue-router'
import { watch } from 'vue'
const route = useRoute()
watch(() => route.params.id, async (newId) => {
await loadUser(newId)
}, { immediate: true })
</script>22. How does programmatic navigation work?
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
async function handleLogin() {
await login(credentials)
router.push('/dashboard')
// With options:
router.push({ name: 'UserProfile', params: { id: 42 }, query: { tab: 'posts' } })
// Replace instead of push (no history entry):
router.replace('/dashboard')
// Go back:
router.go(-1)
}
</script>router.push() adds to the history stack; router.replace() swaps the current entry. Use replace for login/logout redirects so the user cannot press Back and end up on the login page again.
State Management (Questions 23–26)
23. What is Pinia and how does it compare to Vuex?
Pinia is the official state management library for Vue 3. It replaces Vuex as the recommended solution.
| | Vuex 4 | Pinia |
|---|---|---|
| Mutations | Required (separate sync layer) | Not needed — mutate state directly |
| TypeScript | Requires useStore workarounds | First-class, inferred automatically |
| DevTools | Supported | Supported (time-travel, etc.) |
| Tree-shaking | Limited | Full — unused stores are not bundled |
| Modules | Namespaced modules pattern | Every store is a module by default |
// Pinia store (Composition API style)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const isLoggedIn = computed(() => user.value !== null)
async function login(credentials) {
const res = await api.post('/auth/login', credentials)
user.value = res.data.user
}
function logout() {
user.value = null
}
return { user, isLoggedIn, login, logout }
})<script setup>
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
// auth.user, auth.isLoggedIn, auth.login(), auth.logout()
</script>24. What does Vuex's strict separation of mutations vs. actions solve?
In Vuex, state can only be changed via mutations (synchronous). Async work goes in actions, which call mutations when they complete.
// Vuex store
export default new Vuex.Store({
state: { users: [], loading: false },
mutations: {
SET_USERS(state, users) { state.users = users },
SET_LOADING(state, val) { state.loading = val }
},
actions: {
async fetchUsers({ commit }) {
commit('SET_LOADING', true)
const users = await api.getUsers()
commit('SET_USERS', users)
commit('SET_LOADING', false)
}
},
getters: {
activeUsers: (state) => state.users.filter(u => u.active)
}
})This strict separation makes every state change trackable in DevTools (time-travel debugging). Pinia drops the mutations layer — the cost is that async state changes are not as granularly tracked, but Pinia compensates with better TypeScript inference and simpler code.
25. When would you choose local component state vs. a global store?
Decision criteria:
- Local state — used only within one component or a small parent-child subtree. Form values, modal open/close, hover state, tab selection.
- Global store (Pinia) — used by multiple unrelated parts of the app. Authentication, shopping cart, notifications, user preferences, fetched data shared across routes.
A common interview anti-pattern to mention: moving everything to a store "for convenience." This creates coupling, makes components hard to reuse, and pollutes the DevTools state tree.
26. How do you persist Pinia state across page reloads?
Use the pinia-plugin-persistedstate plugin or implement it manually:
// Manual persistence composable
import { watch } from 'vue'
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('settings', () => {
const theme = ref(localStorage.getItem('theme') ?? 'light')
watch(theme, (val) => localStorage.setItem('theme', val))
return { theme }
})For more complex needs, pinia-plugin-persistedstate handles serialization, storage selection, and partial-state persistence.
Performance Optimization (Questions 27–31)
27. What strategies do you use to optimize Vue application performance?
A complete answer covers multiple layers:
Bundle size:
- Route-level code splitting with dynamic imports
- Component-level lazy loading:
defineAsyncComponent(() => import('./HeavyChart.vue')) - Tree-shake libraries — import only what you use
Rendering:
v-oncefor completely static content (renders once, never re-renders)v-memoto skip subtree re-renders unless specific values changecomputedinstead of methods for derived values (caching)- Stable
keyvalues inv-for
Large lists:
- Virtual scrolling with
@tanstack/vue-virtualorvue-virtual-scrollerfor lists with thousands of items - Pagination or infinite scroll
Components:
to cache expensive components between route switches- Functional components for pure presentational nodes (no state, no lifecycle)
<!-- Keep the dashboard cached when navigating away -->
<RouterView v-slot="{ Component }">
<KeepAlive :include="['Dashboard']">
<component :is="Component" />
</KeepAlive>
</RouterView>28. What is `<KeepAlive>` and what are its `include`/`exclude` props?
wraps a dynamic component and caches its state instead of destroying it when it becomes inactive. This avoids re-fetching data and re-running expensive setup.
<KeepAlive :include="['UserDashboard', 'AnalyticsPage']" :max="5">
<component :is="currentView" />
</KeepAlive>include and exclude accept a comma-separated string, an array, or a RegExp matched against the component's name option.
Two extra lifecycle hooks fire only inside components:
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// Fires when component is shown again from cache
// Good place to refresh stale data
})
onDeactivated(() => {
// Fires when component is hidden but kept in cache
})
</script>29. What is `defineAsyncComponent` and when do you use it?
defineAsyncComponent loads a component lazily — the bundle chunk is only fetched when the component is first rendered.
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorMessage,
delay: 200, // ms before showing loading component
timeout: 5000 // ms before showing error component
})Use it for:
- Heavy visualization components (charts, maps, editors)
- Components behind feature flags
- Modal content that most users never open
30. How do you prevent unnecessary re-renders?
<!-- v-once: render once, skip future updates -->
<footer v-once>
<p>© 2025 My Company. All rights reserved.</p>
</footer>
<!-- v-memo: skip subtree unless deps change -->
<div v-for="item in list" :key="item.id" v-memo="[item.selected, item.name]">
<ExpensiveRow :item="item" />
</div>At the JavaScript level:
- Avoid creating new object/array references inside
computedwhen the data hasn't changed - Avoid using methods that create closures in templates (they run on every render)
shallowRefandshallowReactiveskip deep reactivity for large data structures you only replace wholesale
31. How do you handle debouncing and throttling in Vue?
<script setup>
import { ref } from 'vue'
import { debounce } from 'lodash-es'
const query = ref('')
const results = ref([])
const search = debounce(async (q) => {
const res = await fetch(`/api/search?q=${q}`)
results.value = await res.json()
}, 300)
// watch triggers on every keystroke; debounce limits API calls
watch(query, search)
// Clean up on unmount to avoid stale calls
onUnmounted(() => search.cancel())
</script>For scroll/resize handlers, use throttle. Key point for interviewers: always cancel debounced/throttled functions in onUnmounted, or you may call them after the component is gone.
TypeScript Integration (Questions 32–34)
32. How do you type props, emits, and ref with TypeScript in Vue 3?
<script setup lang="ts">
import { ref, computed } from 'vue'
// Typed props using generic syntax
interface User {
id: number
name: string
role: 'admin' | 'user'
}
const props = defineProps<{
user: User
isLoading?: boolean
}>()
// Typed emits
const emit = defineEmits<{
save: [user: User]
cancel: []
}>()
// Typed refs
const inputRef = ref<HTMLInputElement | null>(null)
const count = ref<number>(0)
// Typed computed
const displayName = computed<string>(() =>
props.user.name.toUpperCase()
)
</script>33. How do you type a Pinia store with TypeScript?
The Composition-style Pinia store automatically infers types from the reactive values and functions you return:
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
}
export const useUserStore = defineStore('user', () => {
const currentUser = ref<User | null>(null)
const isAuthenticated = computed(() => currentUser.value !== null)
async function fetchUser(id: number): Promise<void> {
const res = await fetch(`/api/users/${id}`)
currentUser.value = (await res.json()) as User
}
return { currentUser, isAuthenticated, fetchUser }
})
// TypeScript infers the return type of useUserStore() automatically34. What are common TypeScript pitfalls specific to Vue?
- 1Untyped template refs —
ref(null)without a type givesnull | undefined. Always pass the element type:ref.(null) - 2
definePropswith defaults — usewithDefaults(definePropswhen you need both TypeScript typing and default values.(), { ... }) - 3Losing reactivity types — destructuring a
reactive()object or passing a.valuefromrefloses reactivity *and* the type information. UsetoRefs(). - 4
anyin templates — if a computed or prop is typedany, TypeScript cannot catch template errors. Keep types explicit.
<script setup lang="ts">
// withDefaults example
interface Props {
title: string
count?: number
variant?: 'primary' | 'secondary'
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
variant: 'primary'
})
</script>Testing (Questions 35–38)
35. How do you unit test a Vue component with Vitest and Vue Test Utils?
// Button.vue
// <template><button @click="emit('click')">{{ label }}</button></template>
// defineProps<{ label: string }>()
// defineEmits(['click'])
// Button.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Button from './Button.vue'
describe('Button', () => {
it('renders the label', () => {
const wrapper = mount(Button, { props: { label: 'Submit' } })
expect(wrapper.text()).toContain('Submit')
})
it('emits click when pressed', async () => {
const wrapper = mount(Button, { props: { label: 'Go' } })
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
})Philosophy: test user-visible behavior, not implementation details. Assert rendered text, user interactions, emitted events, loading/error states — not internal reactive variables.
36. How do you test a composable?
Composables can be tested without mounting a component at all:
// composables/useCounter.ts
import { ref } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
function increment() { count.value++ }
function reset() { count.value = initial }
return { count, increment, reset }
}
// useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('starts at initial value', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('increments', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
})If the composable uses lifecycle hooks (onMounted, etc.), wrap the call in withSetup:
import { createApp } from 'vue'
function withSetup(composable) {
let result
const app = createApp({ setup() { result = composable(); return () => {} } })
app.mount(document.createElement('div'))
return [result, app]
}37. How do you mock API calls in component tests?
import { mount, flushPromises } from '@vue/test-utils'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import UserList from './UserList.vue'
// Mock the fetch-wrapper module
vi.mock('@/api/users', () => ({
fetchUsers: vi.fn()
}))
import { fetchUsers } from '@/api/users'
describe('UserList', () => {
beforeEach(() => {
vi.mocked(fetchUsers).mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
})
it('shows users after loading', async () => {
const wrapper = mount(UserList)
expect(wrapper.text()).toContain('Loading')
await flushPromises() // wait for all async ops
expect(wrapper.text()).toContain('Alice')
expect(wrapper.text()).toContain('Bob')
})
})flushPromises() from Vue Test Utils resolves all pending promises, making async component testing deterministic.
38. What is the difference between unit, integration, and E2E tests in a Vue app?
| Type | What it tests | Tools |
|---|---|---|
| Unit | Single composable, single utility function | Vitest |
| Component | One component's rendered output and behavior | Vitest + Vue Test Utils |
| Integration | Multiple components working together, store + router | Vitest + Vue Test Utils |
| E2E | Complete user flows in a real browser | Playwright, Cypress |
A pragmatic ratio: write many component tests, fewer integration tests, and a handful of E2E tests for critical paths (login, checkout, core CRUD). E2E tests give the most confidence but are slowest and most brittle.
Advanced & Senior-Level Questions (Questions 39–42)
39. How would you architect a large-scale Vue application?
Feature-based folder structure scales better than type-based:
src/
features/
auth/
components/ LoginForm.vue, AuthGuard.vue
composables/ useAuth.ts
stores/ auth.ts
api/ auth.api.ts
types/ auth.types.ts
routes.ts
products/
components/
composables/
stores/
api/
routes.ts
shared/
components/ Button.vue, Modal.vue, DataTable.vue
composables/ useDebounce.ts, usePagination.ts
utils/
types/
router/
index.ts
stores/ (app-wide stores only: ui.ts, notifications.ts)
App.vue
main.tsKey architectural decisions to mention in an interview:
- Colocate tests next to the files they test (
features/auth/components/__tests__/) - Each feature owns its routes and lazy-loads them
- Keep shared components dependency-free (no store imports)
- Type all API responses — define DTOs and never use
anyfrom fetch calls
40. How do you handle error boundaries and global error handling in Vue?
// Global error handler (main.ts)
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.config.errorHandler = (err, instance, info) => {
// Send to Sentry, Datadog, etc.
console.error('Unhandled Vue error:', err, info)
}Component-level error boundary:
<!-- ErrorBoundary.vue -->
<script setup>
import { ref, onErrorCaptured } from 'vue'
const error = ref(null)
onErrorCaptured((err) => {
error.value = err
return false // prevent propagation
})
</script>
<template>
<slot v-if="!error" />
<div v-else class="error-state">
Something went wrong: {{ error.message }}
</div>
</template><!-- Usage -->
<ErrorBoundary>
<HeavyDataViz :data="chartData" />
</ErrorBoundary>41. How do you implement Server-Side Rendering with Vue?
With Nuxt.js (recommended for most projects):
Nuxt wraps Vue 3 and adds file-based routing, SSR out of the box, server routes (API), and automatic code splitting. A page component renders server-side on first load (HTML sent to browser, good for SEO and TTFB), then Vue hydrates it into a full SPA.
<!-- pages/users/[id].vue — Nuxt file-based route -->
<script setup lang="ts">
const route = useRoute()
// useFetch runs on both server and client
const { data: user } = await useFetch(`/api/users/${route.params.id}`)
</script>
<template>
<div>
<h1>{{ user?.name }}</h1>
</div>
</template>Core SSR concepts to understand:
- Hydration — Vue attaches event listeners to the server-rendered HTML without re-creating the DOM.
- Universal code — code that runs both on server (Node.js) and browser must avoid browser-only APIs like
windowordocumentoutsideonMounted. - Data fetching — fetch data before rendering (
asyncData/useAsyncData) so the HTML includes the content.
42. You have a component that re-renders too frequently. How do you diagnose and fix it?
Step 1 — Diagnose:
// Temporarily add a renderTracked hook to see what's triggering updates
onRenderTracked((event) => {
console.log('Dependency tracked:', event)
})
onRenderTriggered((event) => {
console.log('Re-render triggered by:', event)
})Use Vue DevTools to highlight component updates in real-time.
Step 2 — Common causes and fixes:
| Cause | Fix |
|---|---|
| Unstable key in v-for | Use stable object IDs |
| Method instead of computed | Replace with computed |
| New object reference on every render | Memoize with computed or move outside the component |
| Deep watcher on large object | Use specific paths: watch(() => obj.prop, ...) |
| Parent re-rendering passes new prop reference | Stabilize with shallowRef or memoize in parent |
| Reactive store property too broad | Break into smaller stores or use storeToRefs |
// Instead of watching the whole store object:
watch(store, () => { ... }) // triggers on any store change
// Watch only what you need:
watch(() => store.user.name, (newName) => { ... })Step 3 — Verify fix with renderTriggered. If the hook no longer fires spuriously, you've found and fixed the root cause.
Quick Reference: What Senior Interviewers Listen For
- 1You explain trade-offs, not just features. "I'd use computed here because it caches; I'd switch to watch if I needed an async side effect."
- 2You mention cleanup. Any listener, timer, subscription, or observer set up in a lifecycle hook has a paired teardown.
- 3You know the reactivity edge cases. Destructuring reactive loses reactivity. Replacing a reactive object reference loses reactivity.
refis the safe default. - 4You treat components as contracts. Props are inputs (read-only). Emits are outputs. Slots are content contracts. This maps to the principle of least privilege.
- 5You default to local state. You only promote state to a store when it truly needs to be shared. Stores are not a convenience — they're a coupling decision.
- 6You test behavior, not implementation. You assert what users see, not which reactive variable holds which value.
- 7You understand SSR concerns. Universal code avoids browser APIs outside
onMounted. Hydration mismatches come from server/client data divergence.