Vue.js holds an interesting position in the framework wars. While React dominates in raw job postings and Angular commands enterprise presence, Vue has carved out a devoted following among developers who value its gentle learning curve and elegant API design. Companies like GitLab, Nintendo, and Adobe use Vue extensively, and the framework's adoption continues growing in Asia and Europe particularly.
What makes Vue interviews distinctive is the framework's dual personality: you might be asked about the Options API patterns that made Vue famous, or the newer Composition API that brings it closer to React's hooks model. Candidates who can demonstrate fluency in both approaches, and explain when each shines, stand out immediately.
This guide covers the Vue.js questions that actually appear in 2026 technical interviews, from foundational concepts to the Composition API patterns that senior developers debate.
The 30-Second Overview
When an interviewer asks "Tell me about Vue.js" and you have half a minute, here's what positions you as someone who truly understands the framework:
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 introduced the Composition API alongside the existing Options API, giving developers two paradigms for organizing component logic. The reactivity system uses JavaScript Proxies to automatically track dependencies and update the DOM efficiently. State management moved from Vuex to Pinia, which offers a simpler API with full TypeScript support.
The key insight? Vue's philosophy is "approachable, performant, versatile." If your answer touches on progressive adoption, the dual API options, and the modernized tooling ecosystem (Vite, Pinia, Vue Router 4), you're demonstrating current knowledge.
The 2-Minute Deep Dive
Given more time, expand on how Vue's design philosophy manifests in practice. The framework emerged from Evan You's experience at Google, where he worked with Angular. He wanted something that preserved Angular's data binding and templating while being lighter and more flexible.
The Options API exemplifies this philosophy. It organizes code by option type—data, methods, computed, watch—which creates an immediately readable structure. New developers can look at a Vue component and understand where to find things. This "convention over configuration" approach made Vue famous for its gentle learning curve.
But the Options API has a limitation that becomes painful in complex components: related logic gets scattered. A feature that needs data, a method, a computed property, and a watcher spreads across four different sections of your component. When you need to modify that feature, you're jumping around the file. When you want to extract it for reuse, you're dealing with mixins, which have well-documented problems with naming conflicts and implicit dependencies.
The Composition API addresses this directly. Instead of organizing by option type, you organize by logical concern. All the code for a feature lives together, and extraction becomes natural—just move the logic to a function and return what the component needs. These functions, called composables, have become the standard pattern for sharing logic in Vue 3.
Understanding this evolution from Options to Composition, and when each approach serves you better, demonstrates the kind of thoughtful framework knowledge that interviewers value.
Question 1: What is the Composition API and How Does It Differ from the Options API?
This question appears in virtually every Vue interview because it tests whether candidates have actually worked with Vue 3 or are still operating on Vue 2 knowledge.
Weak answer: "The Composition API uses setup() instead of data and methods. It's the new way to write Vue."
Strong answer: The Composition API is Vue 3's alternative to the Options API for organizing component logic. The key philosophical difference is how you structure code: Options API organizes by type (all data together, all methods together), while Composition API organizes by feature (all code for a feature in one place).
Let me show you what this means in practice. 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
}
}
}Now the real power becomes apparent. That user-fetching logic 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(). Try doing that cleanly with mixins—you'll hit naming conflicts and lose track of where properties come from.
What interviewers are looking for: Understanding that Composition API isn't just different syntax—it solves real problems with code organization and reuse. Candidates who can articulate the scatter problem with Options API and explain how composables provide cleaner extraction demonstrate practical experience.
Common follow-up: "When would you still use the Options API?" Good answers mention simpler components where Options API's structure aids readability, teams transitioning gradually from Vue 2, or cases where the component is unlikely to need logic extraction.
Question 2: How Does Vue's Reactivity System Work?
This is the question that separates developers who've just used Vue from those who understand it deeply enough to debug tricky reactivity issues.
Weak answer: "Vue watches your data and updates the DOM when it changes."
Strong answer: 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.
Here's the mental model. When you wrap data with reactive() or ref(), Vue creates a Proxy around it. When your component renders, Vue tracks which properties you access—these become dependencies. When any dependency changes, Vue knows exactly which components need to re-render.
Let me walk through the mechanics:
import { reactive, ref, effect, computed } 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() works similarly but wraps the value in an object with .value
const count = ref(0)
// Internally: { value: 0 } wrapped in a Proxy
// Access via count.value triggers trackingThe practical implications matter for interviews. Vue 3's Proxy-based system fixed several Vue 2 limitations:
// 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')But there are still gotchas that trip up developers:
// GOTCHA 1: Destructuring loses reactivity
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++
// 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 interviewers are looking for: Beyond the textbook explanation, they want to see you understand the practical implications. Knowing about Proxies is good; knowing that destructuring loses reactivity and when to use toRefs() shows real experience.
Common follow-up: "What's the difference between ref and reactive?" A strong answer: ref() wraps any value (including primitives) in an object with a .value property. reactive() creates a proxy directly on an object. Use ref() for primitives and when you might reassign the whole value; use reactive() when you have an object whose properties you'll modify but won't replace entirely.
Question 3: What Are Computed Properties and When Should You Use Them Over Methods?
This question seems basic but reveals whether candidates understand Vue's caching mechanisms and performance implications.
Weak answer: "Computed properties are cached, methods are not."
Strong answer: Computed properties and methods both let you derive values from your state, but they serve different purposes and have different performance characteristics. The key difference is caching: computed properties cache their results based on their reactive dependencies, while methods execute fresh on every call.
Consider this example:
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 "Calculating total..." logged three times.
This matters significantly when the computation is expensive—filtering large lists, complex calculations, or data transformations. With computed properties, Vue only recalculates when a dependency actually changes.
But here's where the nuance comes in. Computed properties are for deriving data, not for side effects:
// 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 })
}
)There are also cases where methods are the right choice:
// Methods for event handlers - they need fresh context each time
function handleClick(item) {
// Receives the specific item clicked
}
// Methods when you need arguments at call time
function formatPrice(price, currency) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency
}).format(price)
}
// In template: {{ formatPrice(item.price, 'USD') }}
// Can't do this with computed - it would need to be a function
// that returns a function, which defeats the caching benefitWhat interviewers are looking for: The caching explanation is table stakes. What elevates an answer is understanding when caching helps (expensive pure computations) versus when it's irrelevant (event handlers, functions with external arguments) or inappropriate (side effects).
Question 4: Explain Vue's Component Lifecycle Hooks
Lifecycle hooks questions test whether candidates understand component behavior at different stages, which is crucial for proper resource management and API integration.
Weak answer: "There's mounted for when the component loads and destroyed for cleanup."
Strong answer: 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.
Let me walk through the lifecycle with practical use cases:
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 UPDATE: Data changed, but DOM not yet updated
// Good for: Accessing DOM state before Vue updates it
onBeforeUpdate(() => {
// Capture scroll position before DOM changes
const container = document.querySelector('.scroll-container')
previousScrollHeight = container?.scrollHeight
})
// UPDATED: DOM has been patched after data change
// Good for: Post-update DOM operations
// WARNING: Be careful not to trigger infinite loops!
onUpdated(() => {
// Restore scroll position after DOM update
const container = document.querySelector('.scroll-container')
if (container && previousScrollHeight) {
container.scrollTop += container.scrollHeight - previousScrollHeight
}
// DON'T do this - causes infinite loop:
// data.value = newValue // Triggers update, calls onUpdated again
})
// BEFORE UNMOUNT: Component is about to be removed
// Good for: Start cleanup, still have DOM access
onBeforeUnmount(() => {
// Clean up chart before DOM disappears
if (chartRef.value) {
destroyChart(chartRef.value)
}
})
// UNMOUNTED: Component removed from DOM
// Good for: Final cleanup of non-DOM resources
onUnmounted(() => {
// Remove observers and listeners
resizeObserver?.disconnect()
// Cancel any pending requests
abortController?.abort()
// Clear timers
clearInterval(pollInterval)
})
return { data, chartRef }
}
}A common mistake I see is fetching data in onMounted when onBeforeMount would work:
// Both work for data fetching, but subtle difference:
onBeforeMount(async () => {
// Runs before DOM exists
// If fetch is fast, data might be ready before first paint
data.value = await fetchData()
})
onMounted(async () => {
// Runs after DOM exists
// Component renders once empty, then again with data
data.value = await fetchData()
})
// For most cases, onMounted is safer and expected
// But for SSR, you might want different behaviorThe Composition API also introduces hooks that Options API doesn't have:
import { onActivated, onDeactivated, onErrorCaptured } from 'vue'
// For <KeepAlive> components
onActivated(() => {
// Component was re-activated from cache
// Good for refreshing data or resuming timers
})
onDeactivated(() => {
// Component was cached, not destroyed
// Pause expensive operations, but don't full cleanup
})
// Error boundary
onErrorCaptured((error, instance, info) => {
// Catch errors from descendant components
logError(error, info)
return false // Prevent error from propagating further
})What interviewers are looking for: Beyond knowing the hook names, they want to see you understand which hook fits which task. Mentioning cleanup in unmount hooks, the infinite loop danger in onUpdated, and the difference between onBeforeMount and onMounted for data fetching shows practical experience.
Question 5: How Do You Handle State Management in Vue 3?
State management questions reveal whether candidates understand when to use component state, when to reach for global state, and which tools are current in the Vue ecosystem.
Weak answer: "I use Vuex for all state management."
Strong answer: State management in Vue 3 exists on a spectrum, and choosing the right approach depends on what you're managing. The ecosystem has evolved significantly—Pinia has replaced Vuex as the recommended state management library, and the Composition API enables patterns that weren't practical before.
Let me walk through the decision tree:
Local component state is your default. If state only matters to one component, keep it there:
// Simple component state - no store needed
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" />
// Child
const props = defineProps(['items'])
const emit = defineEmits(['select'])
function selectItem(item) {
emit('select', item)
}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' })
const theme = ref('dark')
// Provide with readonly wrapper to prevent mutations
provide('user', readonly(user))
provide('theme', theme)
provide('setTheme', (newTheme) => theme.value = newTheme)
// Deep descendant component
import { inject } from 'vue'
const user = inject('user')
const theme = inject('theme')
const setTheme = inject('setTheme')Pinia is the answer when you need global state that multiple unrelated components access:
// 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
}
}
})Pinia improves on Vuex in several ways that interviewers often ask about:
// No more mutations - just use actions
// Vuex required: mutations for sync, actions for async
// Pinia: actions handle everything
// Full TypeScript support
export const useCartStore = defineStore('cart', {
state: (): CartState => ({
items: [],
discount: 0
}),
// TypeScript infers types throughout
})
// Composition API style stores (alternative syntax)
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 })
// Stores can use each other
// In useCartStore:
// const userStore = useUserStore()
// if (userStore.isVIP) this.discount = 0.2
return { userStore, cartStore }
}
}Composables bridge local and global concerns:
// composables/useAuth.js
// Encapsulates store access with additional logic
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
export function useAuth() {
const store = useUserStore()
const router = useRouter()
const isAuthenticated = computed(() => store.isAuthenticated)
async function login(credentials) {
await store.login(credentials)
router.push('/dashboard')
}
async function logout() {
store.logout()
router.push('/login')
}
return {
user: computed(() => store.user),
isAuthenticated,
login,
logout
}
}What interviewers are looking for: Understanding that state management isn't one-size-fits-all. Explaining the progression from local state to provide/inject to Pinia, and knowing why Pinia replaced Vuex, demonstrates ecosystem awareness.
Question 6: What Are Slots and When Would You Use Scoped Slots?
Slots are fundamental to building flexible, reusable components in Vue. This question tests component design skills.
Weak answer: "Slots let you pass content to components."
Strong answer: Slots are Vue's content distribution mechanism—they let parent components inject content into predefined locations in child components. What makes them powerful is they enable inversion of control: the child component defines the structure, but the parent decides the content.
Regular slots pass content from parent to child:
<!-- 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>But here's where it gets interesting. Sometimes the parent needs access to data that only the child component has. That's where scoped slots come in—they 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'">
<!-- Default rendering if no slot content provided -->
{{ user.name }}
</slot>
</li>
</ul>
</template>
<script setup>
defineProps(['users'])
</script>
<!-- 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. This pattern is incredibly powerful for building reusable components.
Here's a more practical example—a data table component:
<!-- 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>You can also check if slots have content:
<template>
<div class="card">
<!-- Only render header section if slot has content -->
<header v-if="$slots.header" class="card-header">
<slot name="header"></slot>
</header>
<slot></slot>
</div>
</template>
<!-- In Composition API -->
<script setup>
import { useSlots } from 'vue'
const slots = useSlots()
const hasHeader = !!slots.header
</script>What interviewers are looking for: Understanding the inversion of control pattern that slots enable. Scoped slots specifically show you understand that sometimes data flows from child to parent's template content, which is a more advanced concept.
Question 7: How Do You Handle Forms and Validation in Vue?
Form handling reveals practical application development skills and understanding of two-way binding.
Weak answer: "I use v-model to bind form inputs."
Strong answer: Form handling in Vue centers on v-model for two-way binding, but real applications need validation, error handling, and often integration with validation libraries. Let me show you the progression from simple to production-ready.
Basic v-model binds inputs to reactive state:
const form = reactive({
email: '',
password: '',
rememberMe: false
})
// v-model is syntactic sugar for :value + @input
// <input v-model="form.email">
// is equivalent to:
// <input :value="form.email" @input="form.email = $event.target.value">But production forms need validation. Here's a pattern I've used successfully:
<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
})
// Validation rules
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() {
// Validate all fields
Object.keys(rules).forEach(field => {
touched[field] = true
validate(field)
})
if (!isValid.value) return
// Submit logic
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>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="form.password"
type="password"
@blur="handleBlur('password')"
/>
<span v-if="touched.password && errors.password" class="error">
{{ errors.password }}
</span>
</div>
<button type="submit" :disabled="!isValid">Register</button>
</form>
</template>For complex applications, libraries like VeeValidate or FormKit handle the boilerplate:
// VeeValidate example
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'
const schema = yup.object({
email: yup.string().required().email(),
password: yup.string().required().min(8)
})
const { handleSubmit, errors } = useForm({
validationSchema: schema
})
const { value: email } = useField('email')
const { value: password } = useField('password')
const onSubmit = handleSubmit(values => {
// All validation passed
console.log(values)
})Custom form components should work with v-model:
<!-- CustomInput.vue -->
<script setup>
const props = defineProps(['modelValue', 'label', 'error'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<div class="custom-input">
<label>{{ label }}</label>
<input
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
: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"
/>What interviewers are looking for: Understanding that v-model is syntactic sugar and knowing how to implement custom components that work with it. Showing awareness of validation patterns (both manual and library-based) demonstrates practical experience.
Question 8: What Are Vue Router Navigation Guards?
Navigation guards test understanding of authentication flows and route protection—common requirements in real applications.
Weak answer: "Guards check if users can access routes."
Strong answer: Navigation guards are hooks that let you intercept navigations and decide whether to allow, redirect, or cancel them. They're essential for authentication, authorization, data prefetching, and cleanup before leaving pages.
There are three levels of guards, each with different scope:
Global guards apply to all route changes:
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [...]
})
// Runs before every navigation
router.beforeEach(async (to, from) => {
const auth = useAuthStore()
// Check if route requires authentication
if (to.meta.requiresAuth && !auth.isAuthenticated) {
// Redirect to login with return URL
return {
name: 'Login',
query: { redirect: to.fullPath }
}
}
// Check role-based access
if (to.meta.roles && !to.meta.roles.includes(auth.user?.role)) {
return { name: 'Forbidden' }
}
// Prefetch data the route component needs
if (to.meta.prefetch) {
await to.meta.prefetch(to)
}
})
// Runs after navigation completes
router.afterEach((to, from) => {
// Update page title
document.title = to.meta.title || 'My App'
// Track page view in analytics
analytics.page(to.fullPath)
})Per-route guards are defined in the route configuration:
const routes = [
{
path: '/admin',
component: AdminLayout,
beforeEnter: (to, from) => {
// Only runs when entering /admin routes
const auth = useAuthStore()
if (auth.user?.role !== 'admin') {
return { name: 'Forbidden' }
}
},
children: [
{ path: 'users', component: UserManagement },
{ path: 'settings', component: AdminSettings }
]
},
{
path: '/checkout',
component: Checkout,
beforeEnter: async (to) => {
// Validate cart before allowing checkout
const cart = useCartStore()
if (cart.isEmpty) {
return { name: 'Cart', query: { error: 'empty' } }
}
// Prefetch shipping options
await cart.fetchShippingOptions()
}
}
]Component guards are defined in the component itself:
<script>
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
export default {
setup() {
const hasUnsavedChanges = ref(false)
// Warn before leaving with unsaved changes
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
const confirmed = window.confirm(
'You have unsaved changes. Leave anyway?'
)
if (!confirmed) return false
}
})
// When params change but component is reused
// e.g., /users/1 -> /users/2
onBeforeRouteUpdate(async (to, from) => {
// Fetch new user data
await fetchUser(to.params.id)
})
return { hasUnsavedChanges }
}
}
</script>A practical authentication flow combines these:
// router/guards.js
export function setupGuards(router) {
router.beforeEach(async (to) => {
const auth = useAuthStore()
// Always allow public routes
if (to.meta.public) return
// If auth not initialized, wait for it
if (!auth.initialized) {
await auth.initialize()
}
// Auth required but not logged in
if (!auth.isAuthenticated) {
return {
name: 'Login',
query: { redirect: to.fullPath }
}
}
// Logged in but email not verified
if (to.meta.requiresVerification && !auth.user.emailVerified) {
return { name: 'VerifyEmail' }
}
})
}
// Route definitions with meta
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 }
}
]What interviewers are looking for: Understanding the different guard types and their appropriate use cases. The unsaved changes pattern with onBeforeRouteLeave is particularly practical and shows real-world experience.
Question 9: How Does Vue Handle Async Components and Lazy Loading?
Performance optimization questions test whether candidates can build applications that scale.
Weak answer: "You can use dynamic imports to load components when needed."
Strong answer: Async components let you split your bundle so users only download code when they need it. Vue has first-class support for this, and when combined with Vue Router, you get route-based code splitting essentially for free.
The simplest form uses 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, // Show loading after 200ms
timeout: 10000 // Error after 10 seconds
})Route-based splitting is where this shines:
// Before: Everything in main bundle
import Dashboard from './views/Dashboard.vue'
import Settings from './views/Settings.vue'
import Analytics from './views/Analytics.vue'
// After: Each route is a separate chunk
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue')
// Webpack creates "Dashboard.[hash].js" chunk
},
{
path: '/settings',
component: () => import('./views/Settings.vue')
},
{
path: '/analytics',
component: () => import('./views/Analytics.vue'),
// Named chunks for debugging
component: () => import(
/* webpackChunkName: "analytics" */
'./views/Analytics.vue'
)
}
]You can group related routes into the same chunk:
const routes = [
{
path: '/admin',
component: () => import(
/* webpackChunkName: "admin" */
'./views/admin/Layout.vue'
),
children: [
{
path: 'users',
component: () => import(
/* webpackChunkName: "admin" */
'./views/admin/Users.vue'
)
},
{
path: 'settings',
component: () => import(
/* webpackChunkName: "admin" */
'./views/admin/Settings.vue'
)
}
]
}
]
// All admin views bundle togetherPrefetching improves perceived performance:
<template>
<!-- Prefetch when link is visible -->
<router-link
to="/analytics"
@mouseenter="prefetchAnalytics"
>
Analytics
</router-link>
</template>
<script setup>
function prefetchAnalytics() {
// Start loading the chunk before user clicks
import('./views/Analytics.vue')
}
</script>Suspense is Vue 3's way to handle async component trees:
<template>
<Suspense>
<template #default>
<AsyncDashboard />
</template>
<template #fallback>
<LoadingState />
</template>
</Suspense>
</template>
<script setup>
// Works with async setup() in child components
const AsyncDashboard = defineAsyncComponent(
() => import('./Dashboard.vue')
)
</script>
<!-- Dashboard.vue - can have async setup -->
<script setup>
// This makes the component async
const data = await fetchDashboardData()
</script>What interviewers are looking for: Understanding that async components aren't just about lazy loading—they're about user experience. Knowing about loading states, error handling, prefetching strategies, and Suspense shows comprehensive knowledge.
Question 10: How Do You Test Vue Components?
Testing questions reveal whether candidates write maintainable, reliable code.
Weak answer: "I use Jest and Vue Test Utils to test components."
Strong answer: Testing Vue components effectively requires understanding what to test and how to test it. The Vue ecosystem has settled on Vue Test Utils for component testing and Vitest (or Jest) as the test runner.
The key principle is testing behavior, not implementation:
// UserGreeting.vue
<script setup>
import { ref, computed } from 'vue'
const props = defineProps(['user'])
const showDetails = ref(false)
const greeting = computed(() =>
`Hello, ${props.user?.name || 'Guest'}`
)
</script>
<template>
<div class="greeting">
<h1>{{ greeting }}</h1>
<button @click="showDetails = !showDetails">
{{ showDetails ? 'Hide' : 'Show' }} Details
</button>
<div v-if="showDetails" class="details">
Email: {{ user.email }}
</div>
</div>
</template>Testing this component:
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' }
}
})
// Initially hidden
expect(wrapper.find('.details').exists()).toBe(false)
// Click to show
await wrapper.find('button').trigger('click')
expect(wrapper.find('.details').exists()).toBe(true)
expect(wrapper.find('.details').text()).toContain('john@example.com')
// Click to hide
await wrapper.find('button').trigger('click')
expect(wrapper.find('.details').exists()).toBe(false)
})
})Testing components with dependencies requires mocking:
// ProductList.vue that uses a store
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import ProductList from './ProductList.vue'
import { useProductStore } from '@/stores/product'
describe('ProductList', () => {
it('fetches products on mount', async () => {
const wrapper = mount(ProductList, {
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
initialState: {
product: {
products: [
{ id: 1, name: 'Widget', price: 10 },
{ id: 2, name: 'Gadget', price: 20 }
]
}
}
})]
}
})
const store = useProductStore()
expect(store.fetchProducts).toHaveBeenCalled()
expect(wrapper.findAll('.product-item')).toHaveLength(2)
})
it('shows loading state while fetching', async () => {
const wrapper = mount(ProductList, {
global: {
plugins: [createTestingPinia({
initialState: {
product: { products: [], loading: true }
}
})]
}
})
expect(wrapper.find('.loading').exists()).toBe(true)
expect(wrapper.find('.product-item').exists()).toBe(false)
})
})Testing composables directly:
// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubled, increment }
}
// useCounter.test.js
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)
})
})What interviewers are looking for: Testing behavior over implementation, knowing how to handle async operations (await trigger), and understanding how to mock stores and other dependencies. Mentioning what NOT to test (implementation details like internal state) also shows maturity.
Practice Questions
Test your Vue.js knowledge with these questions:
Fundamentals:
- What's the difference between
v-ifandv-show? When would you use each? - Explain why you need
:keyinv-forloops and what happens without it. - How does
v-modelwork internally? How would you implement it for a custom component?
Composition API:
4. What happens when you destructure a reactive object? How do you preserve reactivity?
5. Explain the difference between watch and watchEffect. When would you use each?
6. How do you share state between components using composables without a store?
Architecture:
7. When would you use provide/inject instead of Pinia for state sharing?
8. How would you implement an authentication guard that checks token validity?
9. Design a reusable form field component with validation. What props and events would it need?
Performance:
10. How would you optimize a list that renders thousands of items?
11. When should you use shallowRef or shallowReactive?
12. How do you profile and identify performance issues in a Vue application?
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 |
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
Written by the EasyInterview team
Master Vue.js fundamentals and advanced patterns to ace your next interview. Our interview question flashcards cover these concepts with practice questions and detailed explanations—perfect for structured preparation.
