Vue interviews reward precise reasoning about reactivity, component boundaries, and trade-offs—not memorized API lists. As of September 2026, Vue 3.5 is the stable release line while Vue 3.6 is still available only as a pre-release, so this guide uses stable APIs and calls out version-specific behavior.
The 30 questions below cover both the Options API and Composition API, then move through reactivity, Pinia, Vue Router, async components, and testing. Each answer emphasizes the explanation and caveats a strong interview response should include.
Table of Contents
- Vue.js Fundamentals Questions
- Composition API Questions
- Reactivity System Questions
- Computed Properties and Watchers Questions
- Component Lifecycle Questions
- State Management Questions
- Slots and Component Design Questions
- Forms and Validation Questions
- Vue Router Questions
- Performance and Testing Questions
Vue.js Fundamentals Questions
Understanding Vue's core philosophy and architecture is essential for any interview.
What is Vue.js and what makes it different from other frameworks?
Vue is a progressive JavaScript framework for building user interfaces. Unlike React's JSX-everywhere approach or Angular's full framework commitment, Vue lets you adopt features incrementally—start with just the view layer and add routing, state management, and build tooling as needed.
Vue 3 includes the Composition API alongside the Options API, giving teams two supported ways to organize component logic. Its reactivity system uses Proxies for objects and getter/setter tracking for refs. Pinia is the officially recommended state-management library for new applications; Vuex 4 remains usable in existing Vue 3 codebases.
What is the difference between v-if and v-show?
Both directives control element visibility, but they work differently under the hood. The v-if directive completely removes and recreates the element from the DOM based on the condition. The v-show directive always keeps the element in the DOM but toggles its CSS display property.
Use v-if when the condition rarely changes or when you have expensive components that shouldn't render at all. Use v-show when you need frequent toggling, as the initial render cost is higher but subsequent toggles are cheaper.
Why do you need :key in v-for loops?
The key attribute helps Vue's virtual DOM algorithm identify which items have changed, been added, or been removed in a list. Without keys, Vue uses an "in-place patch" strategy that reuses elements and may cause subtle bugs with component state or animations.
When a list contains stateful components, form controls, transitions, or reorderable items, use stable primitive identifiers such as database IDs. An array index is safe only when item identity and ordering genuinely never change. A key is a reconciliation hint, not a blanket requirement for every trivial list.
Composition API Questions
The Composition API is Vue 3's most significant addition and a common interview topic.
What is the Composition API and how does it differ from the Options API?
The Composition API is an alternative to the Options API for organizing component logic. The main difference is structure: the Options API groups code by option type, while the Composition API can colocate state, derived values, effects, and actions for one feature. Both APIs use the same underlying reactivity system and both are supported.
Consider a component that manages a counter and also fetches user data:
// Options API - logic scattered by type
export default {
data() {
return {
count: 0,
user: null,
loading: false,
error: null
}
},
computed: {
doubleCount() {
return this.count * 2
},
userDisplayName() {
return this.user?.name || 'Anonymous'
}
},
methods: {
increment() {
this.count++
},
async fetchUser(id) {
this.loading = true
try {
const response = await fetch(`/api/users/${id}`)
this.user = await response.json()
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
}
},
mounted() {
this.fetchUser(this.$route.params.id)
}
}The counter logic (count, doubleCount, increment) and the user logic (user, loading, error, fetchUser, userDisplayName) are interleaved throughout the component. Now compare the Composition API approach:
// Composition API - logic grouped by feature
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
export default {
setup() {
// Counter feature - all together
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
const increment = () => count.value++
// User feature - all together
const user = ref(null)
const loading = ref(false)
const error = ref(null)
const userDisplayName = computed(() => user.value?.name || 'Anonymous')
async function fetchUser(id) {
loading.value = true
try {
const response = await fetch(`/api/users/${id}`)
user.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
const route = useRoute()
onMounted(() => fetchUser(route.params.id))
return {
count,
doubleCount,
increment,
user,
loading,
error,
userDisplayName
}
}
}What are composables and why are they better than mixins?
Composables are functions that use Vue's Composition API to encapsulate and reuse stateful logic. They solve the problems that mixins had: implicit dependencies, naming conflicts, and unclear data sources.
The user-fetching logic from the previous example can be extracted into a reusable composable:
// composables/useUser.js
import { ref, computed } from 'vue'
export function useUser() {
const user = ref(null)
const loading = ref(false)
const error = ref(null)
const displayName = computed(() => user.value?.name || 'Anonymous')
async function fetchUser(id) {
loading.value = true
error.value = null
try {
const response = await fetch(`/api/users/${id}`)
user.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
return { user, loading, error, displayName, fetchUser }
}Any component can now use this with a single line: const { user, loading, fetchUser } = useUser(). With mixins, you'd hit naming conflicts and lose track of where properties come from.
When would you still use the Options API?
The Options API remains valuable for simpler components where its structure aids readability, for teams transitioning gradually from Vue 2, or for cases where the component is unlikely to need logic extraction. The Options API's explicit structure—data here, methods there—provides clear organization for straightforward components.
Reactivity System Questions
Understanding Vue's reactivity is crucial for debugging and optimization.
How does Vue's reactivity system work in Vue 3?
Vue's reactivity system automatically tracks which data a component uses and triggers re-renders when that data changes. Vue 3 implements this using JavaScript Proxies, which is a significant improvement over Vue 2's Object.defineProperty approach.
reactive() returns a Proxy for an object. A ref() is a small object whose .value access is tracked through a getter and setter; if its value is an object, Vue makes that value deeply reactive by default. Reads made while a render effect, computed getter, watcher, or watchEffect is active become dependencies. Relevant writes then schedule subscribed effects.
import { reactive, ref, watchEffect } from 'vue'
// When you create reactive state
const state = reactive({
count: 0,
user: { name: 'John' }
})
// Vue wraps it in a Proxy that intercepts gets and sets
// Conceptually, something like this happens internally:
const handler = {
get(target, key) {
track(target, key) // "Someone is reading 'count'"
return target[key]
},
set(target, key, value) {
target[key] = value
trigger(target, key) // "count changed, notify watchers"
return true
}
}
// ref() exposes a tracked .value getter/setter
const count = ref(0)
// Access via count.value triggers tracking
watchEffect(() => {
console.log(state.count, count.value)
})What reactivity limitations did Vue 2 have that Vue 3 fixed?
Vue 3's Proxy-based system fixed several Vue 2 limitations. In Vue 2, you needed $set or Vue.set for array index assignment, length modification, and adding new properties. Vue 3 handles all of these automatically:
// Vue 2 couldn't detect these (needed $set or Vue.set)
const state = reactive({ items: [1, 2, 3] })
// All of these work automatically in Vue 3
state.items[0] = 'new value' // Array index assignment
state.items.length = 0 // Length modification
state.newProperty = 'hello' // Adding new properties
// Vue 2 required workarounds:
// this.$set(this.items, 0, 'new value')
// this.$set(this, 'newProperty', 'hello')What are the common reactivity gotchas in Vue 3?
Even with the improved Proxy-based system, there are patterns that can break reactivity:
// GOTCHA 1: Destructuring a reactive object's primitive property
// disconnects the local binding
const { count } = state // count is now just a number, not reactive
count++ // Won't trigger updates!
// Solution: use toRefs or access directly
const { count } = toRefs(state) // count.value is reactive
// or just: state.count++
// Vue 3.5 exception: values destructured directly from defineProps()
// stay reactive when referenced in the same <script setup> block.
const { userId } = defineProps(['userId'])
watch(() => userId, loadUser) // pass a getter across the watch boundary
// GOTCHA 2: Replacing entire reactive object
let state = reactive({ count: 0 })
state = reactive({ count: 1 }) // Components watching original state won't update
// Solution: mutate properties, don't replace the object
state.count = 1 // Works correctly
// GOTCHA 3: ref unwrapping in reactive objects
const count = ref(0)
const state = reactive({ count }) // count is auto-unwrapped
state.count++ // Works, no .value needed
count.value // Still need .value when accessing ref directly
// GOTCHA 4: Async timing
const message = ref('hello')
message.value = 'world'
console.log(document.body.textContent) // Still shows 'hello'
// DOM updates are batched and asynchronous
import { nextTick } from 'vue'
await nextTick()
console.log(document.body.textContent) // Now shows 'world'What is the difference between ref and reactive?
The ref() function wraps any value (including primitives) in an object with a .value property. The reactive() function creates a proxy directly on an object without the .value wrapper.
Vue's documentation recommends ref() as the primary API for declaring state because it accepts every value type and supports whole-value replacement. reactive() remains convenient for object-shaped state that keeps the same proxy identity. Avoid destructuring primitive properties from a general reactive object unless you convert them with toRefs() or toRef().
Computed Properties and Watchers Questions
These questions test understanding of Vue's caching mechanisms and side effect handling.
What is the difference between computed properties and methods?
Computed properties and methods both let you derive values from your state, but they serve different purposes. The key difference is caching: computed properties cache their results based on their reactive dependencies, while methods execute fresh on every call.
import { ref, computed } from 'vue'
export default {
setup() {
const items = ref([
{ name: 'Product A', price: 100, quantity: 2 },
{ name: 'Product B', price: 50, quantity: 3 },
{ name: 'Product C', price: 75, quantity: 1 }
])
// Computed: cached until items changes
const totalPrice = computed(() => {
console.log('Computing total price...')
return items.value.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
})
// Method: runs every single time it's called
function calculateTotal() {
console.log('Calculating total...')
return items.value.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
}
return { items, totalPrice, calculateTotal }
}
}In the template, if you use {{ totalPrice }} three times, you'll see "Computing total price..." logged only once. Use {{ calculateTotal() }} three times, and you'll see it logged three times.
When should you use watch vs watchEffect?
The watch function explicitly specifies what to watch and runs only when those specific values change. The watchEffect function automatically tracks any reactive dependencies used inside it and runs immediately on setup.
Use watch when you need the previous value, lazy execution, or precise control over the source. Use watchEffect when the effect and its dependencies naturally belong together. With an async watchEffect, only reactive reads made before the first await are tracked.
Async watchers must also cancel stale work. Vue 3.5 added onWatcherCleanup(), which must be registered synchronously:
import { ref, watch, onWatcherCleanup } from 'vue'
const userId = ref('42')
watch(userId, (id) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
fetch(`/api/users/${id}`, { signal: controller.signal })
})Why should computed properties be side-effect free?
Computed properties are for deriving data, not for side effects. Vue may skip recomputing if dependencies haven't changed, and the computation order isn't guaranteed. Side effects in computed properties lead to unpredictable behavior.
// WRONG: Computed with side effects
const filteredUsers = computed(() => {
analytics.track('filtered users') // Side effect!
return users.value.filter(u => u.active)
})
// RIGHT: Use watch for side effects
watch(
() => users.value.filter(u => u.active),
(filtered) => {
analytics.track('filtered users', { count: filtered.length })
}
)Component Lifecycle Questions
Lifecycle hooks questions test understanding of component behavior at different stages.
What are Vue's component lifecycle hooks and when do they run?
Vue components go through several distinct phases, each with hooks that let you run code at specific moments. Understanding these isn't just about knowing the names—it's about knowing which hook is appropriate for different tasks.
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
ref
} from 'vue'
export default {
setup() {
const data = ref(null)
const chartRef = ref(null)
let resizeObserver = null
// BEFORE MOUNT: Reactive setup is done, but no DOM yet
// Good for: Initial data fetching that doesn't need DOM
onBeforeMount(() => {
console.log('Component will mount, DOM not available yet')
// chartRef.value is still null here
})
// MOUNTED: Component is in the DOM
// Good for: DOM manipulation, third-party library initialization
onMounted(async () => {
console.log('Component mounted, DOM available')
// Now safe to access refs and manipulate DOM
initializeChart(chartRef.value)
// Set up observers, event listeners
resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(chartRef.value)
// Fetch data that depends on mounted state
const response = await fetch('/api/data')
data.value = await response.json()
})
// BEFORE UNMOUNT: Component is about to be removed
// Good for: Start cleanup, still have DOM access
onBeforeUnmount(() => {
if (chartRef.value) {
destroyChart(chartRef.value)
}
})
// UNMOUNTED: Component removed from DOM
// Good for: Final cleanup of non-DOM resources
onUnmounted(() => {
resizeObserver?.disconnect()
abortController?.abort()
clearInterval(pollInterval)
})
return { data, chartRef }
}
}onMounted() and onUnmounted() are not called during server-side rendering. Keep browser-only APIs inside client lifecycle hooks and make cleanup symmetric with the setup that created the resource.
What are the onActivated and onDeactivated hooks for?
These hooks are specifically for components wrapped in <KeepAlive>. When a kept-alive component is switched away from, it's cached rather than destroyed, triggering onDeactivated. When it's shown again, onActivated runs instead of the normal mount hooks.
Use these for pausing/resuming expensive operations, refreshing stale data when returning to a cached view, or managing timers that shouldn't run when the component is hidden.
Why should you be careful with onUpdated?
The onUpdated hook runs after the DOM has been patched following a data change. The danger is triggering infinite loops—if you modify reactive state inside onUpdated, it triggers another update, which triggers onUpdated again.
onUpdated(() => {
// DON'T do this - causes infinite loop:
// data.value = newValue // Triggers update, calls onUpdated again
// DO this - post-update DOM operations
const container = document.querySelector('.scroll-container')
container.scrollTop = container.scrollHeight
})State Management Questions
State management questions reveal understanding of when to use different approaches.
How do you decide between local state, props, provide/inject, and Pinia?
State management in Vue 3 exists on a spectrum, and choosing the right approach depends on what you're managing.
Local component state is your default for state that only matters to one component:
const count = ref(0)
const user = ref({ name: '', email: '' })Props and events handle parent-child communication:
// Parent
const selectedItem = ref(null)
function handleSelect(item) {
selectedItem.value = item
}
// <ChildList :items="items" @select="handleSelect" />Provide/Inject shares state across deeply nested components without prop drilling:
// Ancestor component
import { provide, ref, readonly } from 'vue'
const user = ref({ name: 'John', role: 'admin' })
provide('user', readonly(user))
provide('setUser', (newUser) => user.value = newUser)
// Deep descendant component
import { inject } from 'vue'
const user = inject('user')
const setUser = inject('setUser')Pinia fits state or business logic that must be shared across routes or distant parts of the application and outlive a local component. Do not promote every shared value into a store: a composable, lifted state, or provide/inject can remain the simpler boundary.
What is Pinia and how does it compare to Vuex?
Pinia is the officially recommended state-management library for new Vue applications. It offers modular stores, strong TypeScript inference, devtools integration, and no separate mutation layer. Vuex 4 still supports Vue 3, so “Pinia replaced Vuex” describes the recommendation for new work—not a runtime incompatibility or a mandate for immediate migration.
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
token: null,
preferences: {}
}),
getters: {
isAuthenticated: (state) => !!state.token,
displayName: (state) => state.user?.name || 'Guest'
},
actions: {
async login(credentials) {
const response = await api.login(credentials)
this.user = response.user
this.token = response.token
},
logout() {
this.user = null
this.token = null
this.$reset() // Built-in method to reset to initial state
}
}
})How do you define Pinia stores using the Composition API syntax?
Pinia supports an alternative syntax that mirrors the Composition API, which some developers find more natural:
// Composition API style stores
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
// Using stores in components
import { useUserStore, useCartStore } from '@/stores'
export default {
setup() {
const userStore = useUserStore()
const cartStore = useCartStore()
// Direct access to state, getters, actions
console.log(userStore.isAuthenticated)
userStore.login({ email, password })
return { userStore, cartStore }
}
}Slots and Component Design Questions
Slots are fundamental to building flexible, reusable components.
What are slots and how do they enable component reusability?
Slots are Vue's content distribution mechanism—they let parent components inject content into predefined locations in child components. What makes them powerful is inversion of control: the child component defines the structure, but the parent decides the content.
<!-- BaseCard.vue -->
<template>
<div class="card">
<header class="card-header">
<slot name="header">Default Header</slot>
</header>
<main class="card-body">
<slot>Default body content</slot>
</main>
<footer class="card-footer">
<slot name="footer"></slot>
</footer>
</div>
</template>
<!-- Parent component -->
<template>
<BaseCard>
<template #header>
<h2>Custom Title</h2>
</template>
<p>This goes in the default slot (body)</p>
<template #footer>
<button>Save</button>
<button>Cancel</button>
</template>
</BaseCard>
</template>What are scoped slots and when would you use them?
Sometimes the parent needs access to data that only the child component has. Scoped slots let the child pass data back to the parent's slot content:
<!-- UserList.vue -->
<template>
<ul>
<li v-for="user in users" :key="user.id">
<!-- Pass user data to whoever renders this slot -->
<slot :user="user" :isActive="user.status === 'active'">
{{ user.name }}
</slot>
</li>
</ul>
</template>
<!-- Parent with custom rendering -->
<template>
<UserList :users="users" v-slot="{ user, isActive }">
<div :class="{ 'active-user': isActive }">
<img :src="user.avatar" />
<span>{{ user.name }}</span>
<span class="email">{{ user.email }}</span>
<button @click="editUser(user.id)">Edit</button>
</div>
</UserList>
</template>The parent component now controls exactly how each user is displayed, but the child component controls the iteration and provides the data.
How do you build a flexible data table component with scoped slots?
A data table demonstrates scoped slots' power—the table handles structure while parents customize cell rendering:
<!-- DataTable.vue -->
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in data" :key="row.id || index">
<td v-for="col in columns" :key="col.key">
<slot
:name="`cell-${col.key}`"
:value="row[col.key]"
:row="row"
:index="index"
>
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- Parent customizes specific columns -->
<template>
<DataTable :data="products" :columns="columns">
<template #cell-price="{ value }">
<span class="price">${{ value.toFixed(2) }}</span>
</template>
<template #cell-status="{ value, row }">
<StatusBadge :status="value" />
</template>
<template #cell-actions="{ row }">
<button @click="edit(row)">Edit</button>
<button @click="remove(row.id)">Delete</button>
</template>
</DataTable>
</template>Forms and Validation Questions
Form handling reveals practical application development skills.
How does v-model work and how do you implement it for custom components?
The v-model directive is syntactic sugar for binding a value and listening for update events. Understanding this is essential for creating custom form components:
// v-model="form.email" is equivalent to:
// :value="form.email" @input="form.email = $event.target.value"
const form = reactive({
email: '',
password: '',
rememberMe: false
})For custom components in Vue 3.4+, defineModel() is the recommended shorthand. It returns a ref that synchronizes the parent's value with the child's updates:
<!-- CustomInput.vue -->
<script setup>
const model = defineModel({ required: true })
defineProps(['label', 'error'])
</script>
<template>
<div class="custom-input">
<label>{{ label }}</label>
<input
v-model="model"
:class="{ 'has-error': error }"
/>
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
<!-- Usage -->
<CustomInput
v-model="form.email"
label="Email Address"
:error="errors.email"
/>The compiler expands this convention to a modelValue prop plus an update:modelValue event. Be careful with a child-only default in defineModel(): if the parent passes an undefined ref, parent and child can begin out of sync.
How do you implement form validation in Vue?
Production forms need validation, error handling, and user feedback. Here's a pattern that handles validation on blur and on submit:
<script setup>
import { reactive, computed } from 'vue'
const form = reactive({
email: '',
password: '',
confirmPassword: ''
})
const errors = reactive({
email: null,
password: null,
confirmPassword: null
})
const touched = reactive({
email: false,
password: false,
confirmPassword: false
})
const rules = {
email: (value) => {
if (!value) return 'Email is required'
if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email format'
return null
},
password: (value) => {
if (!value) return 'Password is required'
if (value.length < 8) return 'Password must be at least 8 characters'
if (!/[A-Z]/.test(value)) return 'Password must contain uppercase letter'
return null
},
confirmPassword: (value) => {
if (!value) return 'Please confirm your password'
if (value !== form.password) return 'Passwords do not match'
return null
}
}
function validate(field) {
errors[field] = rules[field](form[field])
}
function handleBlur(field) {
touched[field] = true
validate(field)
}
const isValid = computed(() =>
Object.keys(rules).every(field => rules[field](form[field]) === null)
)
async function handleSubmit() {
Object.keys(rules).forEach(field => {
touched[field] = true
validate(field)
})
if (!isValid.value) return
await submitForm(form)
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div class="field">
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
@blur="handleBlur('email')"
:class="{ 'is-error': touched.email && errors.email }"
/>
<span v-if="touched.email && errors.email" class="error">
{{ errors.email }}
</span>
</div>
<button type="submit" :disabled="!isValid">Register</button>
</form>
</template>Client validation improves feedback but is not a security boundary. Repeat all trust-sensitive validation on the server, associate errors with controls (for example with aria-describedby), announce submit-level failures, preserve the user's input, and handle pending, server-error, and retry states.
Vue Router Questions
Navigation guards are essential for authentication and route protection.
What are Vue Router navigation guards and what types exist?
Navigation guards are hooks that let you intercept navigations and decide whether to allow, redirect, or cancel them. Guards can be asynchronous; returning false cancels navigation, returning a route redirects, and throwing rejects the navigation. There are three levels of guards:
Global guards apply to all route changes:
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [...]
})
router.beforeEach(async (to, from) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return {
name: 'Login',
query: { redirect: to.fullPath }
}
}
if (to.meta.roles && !to.meta.roles.includes(auth.user?.role)) {
return { name: 'Forbidden' }
}
})
router.afterEach((to, from) => {
document.title = to.meta.title || 'My App'
analytics.page(to.fullPath)
})Per-route guards are defined in the route configuration:
const routes = [
{
path: '/admin',
component: AdminLayout,
beforeEnter: (to, from) => {
const auth = useAuthStore()
if (auth.user?.role !== 'admin') {
return { name: 'Forbidden' }
}
}
}
]This improves client navigation, but it does not authorize access to protected data. The API must validate the session and permissions on every protected request; route meta and Pinia state are controlled by the browser.
Component guards are defined in the component itself:
<script>
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
export default {
setup() {
const hasUnsavedChanges = ref(false)
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const confirmed = window.confirm('You have unsaved changes. Leave anyway?')
if (!confirmed) return false
}
})
onBeforeRouteUpdate(async (to, from) => {
// When params change but component is reused: /users/1 -> /users/2
await fetchUser(to.params.id)
})
return { hasUnsavedChanges }
}
}
</script>How do you implement authentication with route guards?
A practical authentication flow combines global guards with route meta:
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.public) return
if (!auth.initialized) {
await auth.initialize()
}
if (!auth.isAuthenticated) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
if (to.meta.requiresVerification && !auth.user.emailVerified) {
return { name: 'VerifyEmail' }
}
})
const routes = [
{ path: '/login', component: Login, meta: { public: true } },
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } },
{ path: '/settings', component: Settings, meta: { requiresAuth: true, requiresVerification: true } }
]Performance and Testing Questions
Performance optimization and testing questions reveal senior-level understanding.
How do async components and lazy loading work in Vue?
Async components let you split your bundle so users only download code when needed. Vue has first-class support for this with defineAsyncComponent:
import { defineAsyncComponent } from 'vue'
// Basic async component
const AsyncDashboard = defineAsyncComponent(() =>
import('./components/Dashboard.vue')
)
// With loading and error states
const AsyncHeavyChart = defineAsyncComponent({
loader: () => import('./components/HeavyChart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 10000
})Route-based splitting is particularly effective:
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue')
},
{
path: '/analytics',
component: () => import(
/* webpackChunkName: "analytics" */
'./views/Analytics.vue'
)
}
]Lazy loading reduces initial JavaScript only when the split matches real navigation or interaction boundaries. Confirm the result with bundle analysis and browser performance measurements; excessive tiny chunks can add request and execution overhead. Vue Router's lazy route components are distinct from defineAsyncComponent() wrappers and do not by themselves trigger a parent <Suspense> boundary.
How do you use Suspense with async components?
<Suspense> coordinates nested async dependencies and can show a single fallback while they resolve. As of September 2026 it is still explicitly experimental, its API may change, and it does not provide its own error handling. Use onErrorCaptured() or an appropriate parent error boundary strategy, and prefer an async component's own loading/error options when that simpler contract is enough.
<template>
<Suspense>
<template #default>
<AsyncDashboard />
</template>
<template #fallback>
<LoadingState />
</template>
</Suspense>
</template>
<script setup>
const AsyncDashboard = defineAsyncComponent(
() => import('./Dashboard.vue')
)
</script>How do you test Vue components?
Vue Test Utils is Vue's official low-level component testing library, typically paired with Vitest in Vite projects. Test public behavior—rendered output, user interactions, emitted events, props, and slots—rather than component internals. Use browser component tests when correctness depends on native browser behavior, layout, or styles.
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import UserGreeting from './UserGreeting.vue'
describe('UserGreeting', () => {
it('displays user name in greeting', () => {
const wrapper = mount(UserGreeting, {
props: {
user: { name: 'John', email: 'john@example.com' }
}
})
expect(wrapper.find('h1').text()).toBe('Hello, John')
})
it('displays Guest when no user provided', () => {
const wrapper = mount(UserGreeting, {
props: { user: null }
})
expect(wrapper.find('h1').text()).toBe('Hello, Guest')
})
it('toggles details visibility on button click', async () => {
const wrapper = mount(UserGreeting, {
props: {
user: { name: 'John', email: 'john@example.com' }
}
})
expect(wrapper.find('.details').exists()).toBe(false)
await wrapper.find('button').trigger('click')
expect(wrapper.find('.details').exists()).toBe(true)
expect(wrapper.find('.details').text()).toContain('john@example.com')
await wrapper.find('button').trigger('click')
expect(wrapper.find('.details').exists()).toBe(false)
})
})How do you test composables?
Pure composables that only use reactivity can be tested directly without mounting components:
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('starts with initial value', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('increments count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('computes doubled value', () => {
const { count, doubled, increment } = useCounter(3)
expect(doubled.value).toBe(6)
increment()
expect(doubled.value).toBe(8)
})
})If a composable uses lifecycle hooks, provide/inject, plugins, or DOM APIs, mount a small host component so Vue creates the required component context, then unmount it during cleanup.
Quick Reference
| Concept | Options API | Composition API |
|---|---|---|
| Reactive state | data() | ref(), reactive() |
| Computed | computed: {} | computed() |
| Methods | methods: {} | Plain functions |
| Watch | watch: {} | watch(), watchEffect() |
| Lifecycle | mounted() | onMounted() |
| Props | props: [] | defineProps() |
| Events | $emit() | defineEmits() |
| Template refs | this.$refs | ref() + ref="name" |
| State Management | Use When |
|---|---|
| Local state | Single component needs it |
| Props/Events | Parent-child communication |
| Provide/Inject | Deep component tree, avoiding prop drilling |
| Pinia | Multiple unrelated components share state |
| Composables | Encapsulating and reusing stateful logic |
Frequently Asked Questions
What is the difference between Options API and Composition API in Vue?
The Options API groups component code by options such as data, methods, and computed. The Composition API groups related logic with setup functions and composables. Vue supports both: choose based on team conventions, reuse needs, component complexity, and TypeScript requirements rather than treating one as universally superior.
How does Vue's reactivity system work in Vue 3?
Vue 3 uses Proxies for reactive objects and getter/setter access for refs. Property reads performed by an active render effect, computed getter, watcher, or watchEffect are tracked as dependencies; relevant writes trigger subscribed effects. This also supports array index changes and newly added properties.
What are composables in Vue 3 and why use them?
Composables are functions that use Vue's Composition API to encapsulate and reuse stateful logic. Unlike mixins, composables have explicit dependencies, better TypeScript support, and no naming conflicts. They follow the 'use' prefix convention (useCounter, useFetch) and return reactive state and methods that components can use.
What replaced Vuex in Vue 3?
Pinia is the official recommended state-management library for new Vue applications. It offers modular stores, strong TypeScript inference, devtools integration, and no separate mutation layer. Vuex 4 still works with Vue 3, so existing applications can migrate deliberately rather than assuming Vuex suddenly stopped working.
What is the difference between ref and reactive in Vue 3?
ref() accepts primitives or objects and exposes the value through .value in JavaScript; object values are made deeply reactive by default. reactive() returns a deep proxy and accepts only objects. Vue recommends ref() as the primary state API because it supports whole-value replacement and is easier to pass across boundaries.
How do you handle props in Vue 3 Composition API?
Declare props with defineProps() in script setup or read them from setup(props). Props are reactive and readonly, so derive values with computed or emit an event instead of mutating them. In Vue 3.5+, destructured defineProps variables remain reactive in the same script-setup block; pass a getter when a composable or watcher needs one.
Sources
- Vue core changelog
- Vue: Reactivity Fundamentals
- Vue: Reactivity in Depth
- Vue: Watchers
- Vue: Props and reactive destructuring
- Vue: Component
v-model - Vue: Async Components
- Vue: Suspense
- Pinia: Introduction and comparison with Vuex
- Vue Router: Navigation Guards
- Vue: Testing Guide
Related Articles
If you found this helpful, check out these related guides:
- Complete Frontend Developer Interview Guide - comprehensive preparation guide for frontend interviews
- JavaScript Closures Interview Guide - Essential for understanding Vue's Composition API and reactivity
- 9 Advanced React Interview Questions - Compare React patterns with Vue's approach
- TypeScript Type vs Interface - TypeScript knowledge for Vue 3 with full type support
- JavaScript Event Loop Interview Guide - Understand async behavior in Vue applications
