The 60 Vue questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
Vue is a progressive JavaScript framework for building user interfaces, created by Evan You and first released in 2014. Progressive means you can adopt it incrementally: drop it into one page with a script tag, or build a full single-page application with routing, state management, and a build step. Its core idea is reactivity: you declare data, bind it in a template, and Vue updates the DOM when that data changes. The official Vue documentation describes it as building on standard HTML, CSS, and JavaScript, which is why the template syntax feels close to plain markup. In interviews, Vue questions probe how reactivity actually works (ref versus reactive, computed versus watch), how components talk to each other (props down, events up, slots, provide/inject), and the Vue 3 shift toward the Composition API. This page collects the 60 questions that come up most, each with a direct answer and runnable code. Increasingly the first frontend round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Vue.js Explained in 100 Seconds
Video: Vue.js Explained in 100 Seconds (Fireship, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Vue certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
Vue is a JavaScript framework for building user interfaces, based on a reactive data model and reusable components. Progressive means you can adopt it in stages: sprinkle it onto one page, or scale up to a full single-page app with routing and state management.
Its draw is a gentle learning curve and single-file components that keep template, script, and styles for one component in one file. You bind data in a template, and Vue updates the DOM automatically when that data changes.
Key point: A one-line definition plus the word 'reactive' and one adoption example beats a feature list. Interviewers open with this to hear how you organize an answer.
Watch a deeper explanation
Video: Vue JS 3 Tutorial for Beginners #1 - Introduction (The Net Ninja, YouTube)
Reactivity means the view updates automatically when the underlying data changes. In Vue 3 you declare reactive state with ref() for single values and reactive() for objects, and Vue tracks which parts of the template depend on which pieces of state.
When you change a ref's .value or a reactive object's property, Vue re-runs only the parts of the render that used it. You never manually touch the DOM.
import { ref, reactive } from 'vue'
const count = ref(0) // single value
const user = reactive({ name: 'Asha', age: 31 })
count.value++ // .value in script
user.age = 32 // property directly
// the template updates automaticallyKey point: Saying 'the template re-renders only where the data is used' shows you understand tracking, not just that data is 'live'.
ref() wraps any value (primitive or object) in a reactive box you read and write through .value in script. reactive() takes an object and returns a reactive proxy you use directly, with no .value.
Use ref for primitives and when you need to reassign the whole value; use reactive for objects when you won't replace the reference. A common gotcha: destructuring a reactive object breaks reactivity, while refs survive destructuring.
import { ref, reactive } from 'vue'
const n = ref(0)
n.value = 5 // ref: use .value
const state = reactive({ n: 0 })
state.n = 5 // reactive: direct
const { n: broken } = reactive({ n: 0 }) // loses reactivity| ref | reactive | |
|---|---|---|
| Wraps | Any value | Objects only |
| Access | value.value | value.prop |
| Reassign whole value | Yes | No (breaks reactivity) |
| Survives destructuring | Yes | No |
Key point: The follow-up is 'why does destructuring reactive break it?'. Answer: the proxy is on the object, and destructuring copies plain values out.
A computed property derives a value from reactive state and caches the result. It only recomputes when one of its reactive dependencies changes, so reading it repeatedly is free between changes.
A method called in the template runs on every re-render, whether its inputs changed or not. Use computed for derived values you display; use a method for actions triggered by events.
import { ref, computed } from 'vue'
const price = ref(100)
const qty = ref(3)
const total = computed(() => price.value * qty.value) // cached
// total recomputes only when price or qty changesKey point: The word the question needs is 'cached'. Mention it and the reason (dependency tracking) and this question is done.
Use computed when you need a new value derived from existing state: a total, a filtered list, a formatted string. It's declarative and cached.
Use watch when a state change should trigger a side effect: calling an API, saving to storage, or manually reacting to a change. If you find yourself setting state inside a computed getter, you actually wanted a watcher.
import { ref, computed, watch } from 'vue'
const query = ref('')
const upper = computed(() => query.value.toUpperCase()) // derived value
watch(query, (newVal) => {
fetchResults(newVal) // side effect on change
})Key point: The rule of thumb the technical value is: computed for a value, watch for an action.
Directives are special attributes starting with v- that tell Vue to do something to an element. The everyday set: v-bind (bind an attribute), v-on (listen to an event), v-model (two-way binding), v-if / v-else / v-show (conditional rendering), and v-for (list rendering).
They have shorthands you'll see everywhere: colon for v-bind (:href) and at-sign for v-on (@click). You can also write custom directives for low-level DOM access.
<a :href="url">link</a> <!-- v-bind shorthand -->
<button @click="save">Save</button> <!-- v-on shorthand -->
<input v-model="name" /> <!-- two-way binding -->
<li v-for="item in items" :key="item.id">{{ item.label }}</li>v-if conditionally adds or removes the element from the DOM entirely: when false, the element and its component aren't rendered at all. v-show always renders the element and toggles it with the CSS display property.
v-if has higher toggle cost but no initial cost when false; v-show has higher initial cost but cheap toggling. Use v-if for things that rarely change, v-show for things toggled often like a dropdown.
| v-if | v-show | |
|---|---|---|
| When false | Element removed from DOM | Element stays, display: none |
| Toggle cost | Higher (mount/unmount) | Lower (CSS only) |
| Initial cost if false | None | Still renders |
| Best for | Rarely toggled | Frequently toggled |
Key point: The right coverage names the trade-off both ways, not just 'one removes and one hides'.
v-for renders a list by iterating an array, object, or range: v-for="item in items". You bind a unique key to each item so Vue can track identity across updates.
Without a stable key, Vue patches nodes in place by index, which corrupts state (like input focus or checkbox state) when the list reorders or items are inserted. Use a stable unique id, never the array index when the list can change.
<ul>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
<!-- key = user.id, not the loop index -->Key point: 'Why not use the index as key?' is the standard follow-up. Answer: it breaks on reorder or insertion because the identity shifts.
v-model creates two-way binding on a form element: it binds the value and listens for input in one directive, so the state and the field stay in sync.
Under the hood on a text input it's shorthand for :value plus @input updating that value. On a custom component in Vue 3, v-model binds modelValue and listens for update:modelValue.
<!-- these two are equivalent on a text input -->
<input v-model="text" />
<input :value="text" @input="text = $event.target.value" />Props are how a parent passes data down to a child component. You declare them in the child, and the parent binds values with attributes. Props are one-way: data flows down, and a child should not mutate a prop directly.
If a child needs to change a prop's value, it emits an event so the parent updates the source, or it copies the prop into local state. Declaring prop types and defaults documents the component and catches mistakes.
// child
defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
})Key point: The one-way rule (never mutate a prop) is what this question really checks. State it directly.
The child emits a custom event, and the parent listens for it with v-on. This keeps data flow predictable: props down, events up. In Vue 3 you declare emitted events with defineEmits and call the returned emit function.
The parent binds a handler like @save="onSave" and receives whatever payload the child passed to emit.
// child
const emit = defineEmits(['save'])
function submit() {
emit('save', { id: 1, name: 'Asha' })
}
// parent template:
// <UserForm @save="onSave" />Lifecycle hooks run at set points in a component's life: onBeforeMount and onMounted (DOM insertion), onBeforeUpdate and onUpdated (reactive change causes a re-render), and onBeforeUnmount and onUnmounted (teardown).
The two you use most: onMounted for work needing the real DOM or a first data fetch, and onUnmounted to clean up timers, listeners, or subscriptions so they don't leak.
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
const id = setInterval(tick, 1000)
onUnmounted(() => clearInterval(id)) // clean up
})Key point: the key signal is cleanup awareness. Pairing onMounted setup with onUnmounted teardown is the signal.
A single-file component is a .vue file that holds a component's template, script, and styles together. The template block is the markup, the script block is the logic, and an optional style block holds CSS that can be scoped to the component.
This colocation keeps everything about one component in one place. With script setup, the script block is more concise: top-level bindings are automatically exposed to the template.
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
<style scoped>
button { padding: 8px; }
</style>Text interpolation uses double curly braces: {{ expression }} renders a reactive value as text and updates when it changes. For attributes you can't use curly braces; you use v-bind (or its colon shorthand) instead.
Templates accept single JavaScript expressions, not statements, so you can call a method or do a ternary but not write an if block or a loop inline.
<h1>{{ title }}</h1>
<span>{{ isNew ? 'New' : 'Old' }}</span>
<img :src="imageUrl" :alt="title" />You listen with v-on or its @ shorthand: @click="handler". The handler can be a method name or an inline expression. Event modifiers are suffixes that handle common needs without boilerplate.
Common modifiers: .prevent (calls preventDefault), .stop (stops propagation), .once (fires once), and key modifiers like @keyup.enter. They keep handlers focused on logic instead of DOM plumbing.
<form @submit.prevent="onSubmit">
<input @keyup.enter="search" />
<button @click.once="init">Start</button>
</form>Bind class with :class using an object (class name to boolean) or an array. Bind style with :style using an object of CSS properties. Vue merges these with any static class or style on the element.
The object syntax is the common one: a class is applied when its value is truthy, which reads cleanly for state-driven styling.
<div :class="{ active: isActive, error: hasError }">...</div>
<div :style="{ color: textColor, fontSize: size + 'px' }">...</div>The Options API organizes a component by option type: a data function, a methods object, computed, watch, and lifecycle options. The Composition API organizes by logical concern inside setup (or script setup), using functions like ref, computed, and the on-hooks.
The Composition API's advantage shows on larger components: related logic stays together instead of scattering across option blocks, and you can extract reusable logic into composables. Vue 3 supports both.
// Composition API (script setup)
import { ref, computed } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
function inc() { count.value++ }Key point: Say you can use both but new code favors Composition. Explaining 'related logic stays together' is the depth marker.
In Vue 3, v-if has higher priority than v-for when on the same element, so the condition can't access the loop variable, which is usually the opposite of what people expect. It also mixes two concerns on one node.
The fix is to filter the list first with a computed property, or wrap the loop in a template tag with the v-if on an outer element. This keeps rendering intent clear.
<!-- avoid: v-if and v-for together -->
<!-- prefer: filter in a computed -->
<li v-for="u in activeUsers" :key="u.id">{{ u.name }}</li>
<!-- activeUsers = computed(() => users.filter(u => u.active)) -->The current recommendation is create-vue, which sets up a Vite-based project: run npm create vue@latest, pick options like router, Pinia, and testing, and you get a fast dev server with hot module replacement.
Vite replaced the older Vue CLI (webpack-based) tooling as the default for its faster startup and builds. Vite by name matters.
npm create vue@latest
# prompts for router, Pinia, TypeScript, tests
cd my-app && npm install && npm run devThe data flow between components is one-way: props go down from parent to child, and the child reports changes up through events. v-model looks two-way but it's really that same pair (a value binding plus an event) bundled into one directive.
So the mental model is one-way binding with a two-way convenience layer on form fields. Keeping the flow one-way is what makes state changes traceable to a single source.
Key point: Interviewers ask this to catch the myth that Vue is 'two-way like Angular 1'. Explaining v-model as sugar over props-plus-events is the correct nuance.
Yes. A computed is read-only by default, but you can pass an object with get and set functions to make it writable. The getter derives the value; the setter runs when you assign to the computed, usually updating the underlying state.
A common use is a full-name computed that splits an assignment back into first and last name, or wrapping a store value so a v-model can bind to it.
import { ref, computed } from 'vue'
const first = ref('Asha'), last = ref('Rao')
const fullName = computed({
get: () => `${first.value} ${last.value}`,
set: (val) => { [first.value, last.value] = val.split(' ') },
})
fullName.value = 'Ben Cole' // updates first and lastFor candidates with working experience: reactivity mechanics, component patterns, and the questions that separate users from understanders.
script setup is compile-time sugar for the Composition API in single-file components. Everything at the top level (imports, variables, functions) is automatically available to the template, so you skip a return statement and boilerplate.
It also gives compiler macros like defineProps, defineEmits, and defineExpose that don't need importing. It's the recommended way to write Vue 3 components.
<script setup>
import { ref } from 'vue'
const props = defineProps({ label: String })
const emit = defineEmits(['change'])
const value = ref('')
</script>
<template>
<input v-model="value" :aria-label="props.label" />
</template>Watch a deeper explanation
Video: Vue JS Crash Course (Traversy Media, YouTube)
A composable is a function that uses Composition API features to encapsulate and reuse stateful logic. By convention its name starts with use, like useMouse or useFetch. It returns reactive state and functions the component can bind to.
Composables are the Vue 3 answer to logic reuse: they replace mixins, which suffered from name collisions and unclear sources. Each call gets its own isolated state.
// useCounter.js
import { ref } from 'vue'
export function useCounter(start = 0) {
const count = ref(start)
const inc = () => count.value++
return { count, inc }
}
// component: const { count, inc } = useCounter(10)Key point: 'How is this better than mixins?' is the follow-up. Answer: explicit sources, no name collisions, isolated state per call.
Watch a deeper explanation
Video: Vue.js Course for Beginners (freeCodeCamp.org, YouTube)
watch takes an explicit source (a ref, a getter, or an array) and a callback, giving you both the new and old values and running only when that source changes. It's lazy by default: it doesn't run until the first change.
watchEffect runs immediately and automatically tracks every reactive value used inside it, re-running when any of them change. Use watch when you need the old value or precise control; use watchEffect for effects that depend on several values you don't want to list.
import { ref, watch, watchEffect } from 'vue'
const a = ref(0), b = ref(0)
watch(a, (val, old) => console.log(val, old)) // explicit, lazy, old value
watchEffect(() => console.log(a.value + b.value)) // auto-tracks a and b, runs now| watch | watchEffect | |
|---|---|---|
| Source | Explicit | Auto-tracked from body |
| Runs immediately | No (lazy) | Yes |
| Old value | Available | Not available |
| Best for | Precise, need old value | Multi-dependency effects |
Slots let a parent pass template content into a child's layout. A default slot is a single placeholder; named slots let a child define multiple insertion points (header, body, footer) the parent fills by name.
Scoped slots pass data from the child back up to the slot content, so the parent can render child data its own way. That's the pattern behind flexible list and table components.
<!-- child: List.vue -->
<slot name="item" :row="row"></slot>
<!-- parent -->
<List>
<template #item="{ row }">
<strong>{{ row.name }}</strong>
</template>
</List>Key point: Being able to explain a scoped slot (child gives data to parent's template) separates people who've built reusable components from people who've only used them.
provide lets a component supply a value, and any descendant, however deep, reads it with inject. It avoids prop drilling: passing a prop through many intermediate components that don't use it.
Use it for things a whole subtree needs, like a theme, a locale, or a form context. For app-wide state prefer a store like Pinia; provide/inject shines for component-library context, not global app state.
// ancestor
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
// deep descendant
import { inject } from 'vue'
const theme = inject('theme', 'light') // default fallbackVue 3's Proxy-based reactivity fixed most Vue 2 caveats: you can add and delete object properties and set array items by index and reactivity still works. But some traps remain worth naming.
Destructuring a reactive object loses reactivity, so use toRefs when you need to destructure. Replacing a reactive object's reference (state = newObj) breaks the link. And plain values passed out of setup won't be reactive unless wrapped in a ref.
import { reactive, toRefs } from 'vue'
const state = reactive({ a: 1, b: 2 })
const { a, b } = toRefs(state) // keeps reactivity
// now a.value and b.value stay linked to stateKey point: Mentioning toRefs in practice is a strong intermediate signal. It shows you've hit the destructuring trap in real code.
Vue Router maps URL paths to components. You define routes, render a router-view where matched components appear, and handle with router-link or programmatically via the router instance. Dynamic segments like /user/:id expose params to the component.
It supports nested routes, navigation guards for auth checks, and lazy-loaded route components for code splitting, which keeps the initial bundle small.
const routes = [
{ path: '/user/:id', component: () => import('./User.vue') }, // lazy
]
// in a component
import { useRoute } from 'vue-router'
const route = useRoute()
console.log(route.params.id)Pinia is the official state management library for Vue 3. A store holds state, getters (computed values), and actions (methods that change state). Any component can use the store, and its state is reactive across the whole app.
It's simpler than Vuex: no mutations, direct state changes inside actions, and strong TypeScript inference. You define a store with defineStore and call it inside setup.
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCart = defineStore('cart', () => {
const items = ref([])
const count = computed(() => items.value.length)
function add(item) { items.value.push(item) }
return { items, count, add }
})Watch a deeper explanation
Video: Learn Vue.js - Full Course for Beginners (freeCodeCamp.org, YouTube)
Pinia removed the parts of Vuex that added ceremony without value. Vuex separated synchronous mutations from asynchronous actions and used string-based module namespacing. Pinia drops mutations entirely: actions change state directly, and stores are flat modules with no namespace strings.
Pinia also has better TypeScript inference and a smaller API. Vuex still exists for legacy Vue 2 and Vue 3 apps, but new projects use Pinia.
| Pinia | Vuex | |
|---|---|---|
| Mutations | None (actions change state) | Required, separate from actions |
| Modules | Flat, multiple stores | Nested with namespacing |
| TypeScript | Inferred out of the box | Needs extra typing work |
| Status in Vue 3 | Recommended | Legacy / maintenance |
A dynamic component uses the built-in component element with an :is binding to swap which component renders at runtime, useful for tabs or wizards. An async component is defined with defineAsyncComponent so its code loads on demand.
Async components enable route-level and component-level code splitting: the chunk downloads only when the component is actually needed, shrinking the initial bundle.
import { defineAsyncComponent } from 'vue'
const Chart = defineAsyncComponent(() => import('./Chart.vue'))
// template:
// <component :is="currentTab" />
// <Chart /> loads its chunk only when renderedKeepAlive caches component instances instead of destroying them when they're toggled or swapped out. Wrap a dynamic component or a router-view in it, and switching away then back restores the previous state instead of remounting fresh.
It's the tool for preserving form input, scroll position, or fetched data across tab switches. It exposes onActivated and onDeactivated hooks in place of mount and unmount for cached components.
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
<!-- switching tabs keeps each tab's state -->Teleport renders a component's markup at a different place in the DOM than where it's declared, while keeping it logically part of the component. You give it a target selector like body.
It solves the modal and tooltip problem: a dialog defined deep in the tree can render at the document root so parent CSS overflow, transforms, or z-index stacking don't clip it.
<Teleport to="body">
<div class="modal" v-if="open">
<slot />
</div>
</Teleport>Write a custom directive when you need direct, reusable DOM manipulation that doesn't fit a component: autofocus on mount, click-outside detection, or integrating a plain DOM library. Directives get lifecycle hooks like mounted and updated with the raw element.
The guidance is to reach for components first and directives only for low-level DOM access, because directives operate outside Vue's usual data flow.
const vFocus = {
mounted: (el) => el.focus(),
}
// usage: <input v-focus />For small forms, bind fields with v-model, compute validity with computed properties, and show errors conditionally. For anything larger, teams use a library like VeeValidate or Vuelidate that handles rules, touched state, and error messages.
The key idea to convey is derived validity: errors and the submit-disabled state are computed from the current field values, not manually toggled, so they stay in sync automatically.
import { ref, computed } from 'vue'
const email = ref('')
const emailError = computed(() =>
email.value.includes('@') ? '' : 'Enter a valid email'
)
const canSubmit = computed(() => !emailError.value)The built-in Transition component applies enter and leave CSS classes automatically when an element is inserted or removed, so you write the animation in CSS and Vue times the class changes. TransitionGroup does the same for lists, including move animations on reorder.
For JavaScript-driven animation you can hook into the transition events, or integrate a library. The point is Vue handles the mount and unmount timing for you.
<Transition name="fade">
<p v-if="show">Hello</p>
</Transition>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>In Vue 3 a component v-model binds a modelValue prop and emits update:modelValue. You accept the prop and emit the event on change; the parent uses v-model as usual. You can also name models (v-model:title) for multiple bindings.
In newer Vue 3 versions the defineModel macro collapses this to one line, which is worth mentioning as current syntax.
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input :value="modelValue"
@input="emit('update:modelValue', $event.target.value)" />
</template>A template ref gives you a direct reference to a DOM element or child component. You declare a ref with the same name as the ref attribute, and after mount it holds the element. Access it in onMounted, never during setup, because the DOM doesn't exist yet.
Use it sparingly for things Vue's declarative model can't do: focusing an input, measuring size, or calling a child's exposed method.
<script setup>
import { ref, onMounted } from 'vue'
const inputEl = ref(null)
onMounted(() => inputEl.value.focus())
</script>
<template>
<input ref="inputEl" />
</template>Vue batches reactive updates and applies DOM changes asynchronously for efficiency. nextTick returns a promise that resolves after Vue has flushed those updates to the DOM, so you can read the updated DOM reliably.
You need it when you change state and then immediately need to measure or act on the resulting DOM: after adding a list item, scroll to it; after showing an input, focus it.
import { nextTick, ref } from 'vue'
const items = ref([])
async function add() {
items.value.push('new')
await nextTick() // DOM now updated
scrollToBottom()
}Local registration imports a component into the file that uses it, so it's only available there and can be tree-shaken if unused. Global registration with app.component makes it usable in every template without importing.
Prefer local registration in most apps: it makes dependencies explicit and keeps the bundle lean. Reserve global registration for a handful of truly ubiquitous components like a base button or icon.
onErrorCaptured lets a parent catch errors thrown during a descendant's render, lifecycle hooks, or event handlers, so you can log them and show a fallback instead of a blank screen. Returning false stops the error from propagating further.
At the app level, app.config.errorHandler catches everything not caught locally, which is where you send errors to a monitoring service.
import { onErrorCaptured, ref } from 'vue'
const failed = ref(false)
onErrorCaptured((err) => {
failed.value = true
report(err)
return false // stop propagation
})advanced rounds probe internals, architecture judgment, and production scars. Expect every answer here to draw a follow-up.
Vue 3 wraps reactive objects in a JavaScript Proxy that intercepts property reads and writes. On read, it tracks which effect (a render function or watcher) is currently running and records the dependency. On write, it triggers every effect that depended on that property.
This replaced Vue 2's Object.defineProperty approach, which couldn't detect new properties or index assignments and required workarounds. The Proxy sees all operations, which is why Vue 3's reactivity has far fewer caveats.
// the essence, simplified
function reactive(obj) {
return new Proxy(obj, {
get(t, key) { track(t, key); return t[key] },
set(t, key, val) { t[key] = val; trigger(t, key); return true },
})
}Key point: Naming Proxy vs Object.defineProperty and one concrete caveat it fixed (new keys, array index) is the depth this question screens for.
A render produces a virtual DOM tree, and Vue diffs the new tree against the old to patch only what changed. What sets Vue 3 apart is a compiler-informed virtual DOM: the template compiler analyzes markup at build time and marks which parts are static and which are dynamic.
Optimizations like static hoisting (create static nodes once) and patch flags (skip nodes with no dynamic bindings) mean the runtime diff only visits parts that can actually change, so Vue's rendering scales with dynamic content, not template size.
By default reactive() and ref() are deep: nested objects become reactive too, which costs proxy overhead. shallowRef and shallowReactive make only the top level reactive, useful for large data structures where you swap the whole value rather than mutating deep inside.
markRaw tells Vue never to make an object reactive at all, which matters when you store third-party class instances (a chart, a map, a big immutable dataset) that break or slow down when proxied.
import { shallowRef, markRaw } from 'vue'
const bigList = shallowRef(load()) // reactive only when replaced
const chart = markRaw(new ThirdPartyChart()) // never proxiedSSR renders components to an HTML string on the server, sends it to the browser for a fast first paint and better SEO, then hydrates: the client-side Vue app attaches to the existing markup and takes over interactivity. Vue provides the low-level server renderer, but wiring routing, data fetching, and hydration by hand is involved.
Nuxt is the meta-framework that handles all of it: file-based routing, server routes, data fetching that runs on server then client, and hydration. Most teams reach for Nuxt rather than assembling SSR from primitives.
Key point: The follow-up is often 'what is hydration mismatch?'. Answer: server and client render different markup, so Vue can't attach cleanly and warns or re-renders.
Measure first with the Vue Devtools performance tab and the browser profiler to find what actually re-renders and how long it takes. Common culprits: a computed that isn't cached because it reads non-reactive data, huge lists rendered without virtualization, and deep reactivity on large objects.
Fixes in order of impact: virtualize long lists, use v-once or v-memo for expensive static subtrees, mark heavy data with shallowRef or markRaw, code-split routes and components, and check that keys are stable. The discipline (profile, don't guess) is what matters.
Keep each composable focused on one concern, return refs and functions rather than a reactive blob so consumers can destructure freely, and accept configuration as arguments. Clean up side effects (listeners, intervals, subscriptions) inside the composable using onUnmounted so callers don't have to.
The returned values clearly, avoid hidden global state unless intended, and let composables call other composables. The failure mode to name: a mega-composable that does five things and can't be reused, which is just a mixin with new syntax.
export function useEventListener(target, event, handler) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}Vue 3 rewrote reactivity on Proxy (removing the new-property and array-index caveats), added the Composition API, and made the framework tree-shakeable so unused features drop out of the bundle. It also supports multiple root nodes per template (fragments), Teleport, and Suspense.
Migration friction comes from breaking changes: the new app-creation API (createApp), removed filters, changed v-model conventions on components, and the global API reorganization. Large apps often migrate incrementally with the compat build.
| Area | Vue 2 | Vue 3 |
|---|---|---|
| Reactivity | Object.defineProperty | Proxy |
| Logic reuse | Mixins | Composables |
| Root nodes | Single required | Multiple (fragments) |
| Bundle | Full framework | Tree-shakeable |
Suspense is a built-in component that coordinates async dependencies in a subtree: it shows a fallback slot while async setup or async components resolve, then swaps in the default slot. It lets you handle loading states declaratively instead of tracking a loading boolean per component.
The honest senior note: Suspense has been marked experimental for a long time and its API can change, so many teams still manage loading state manually or through their router and data layer. Knowing it exists and its status is the right answer.
Vue Test Utils with Vitest (or Jest) is the standard for component tests: mount a component, interact with it, and assert on rendered output or emitted events. Test behavior a user cares about (does clicking emit the event, does the list render) rather than internal implementation.
For flows across components, Cypress or Playwright drive a real browser. A strong answer separates the layers: unit test composables as plain functions, component-test rendering and events, and end-to-end test critical user journeys.
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'
test('increments on click', async () => {
const wrapper = mount(Counter)
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('1')
})the narrowest scope that works and widen only when needed: local component state for UI concerns, props and events between a parent and its children, provide/inject for a subtree's shared context, and a Pinia store for state many unrelated parts of the app read or write comes first.
The anti-pattern to name is putting everything in the global store, which turns local UI state into global coupling and makes components hard to reuse. Server data is its own category: a query layer like TanStack Query handles caching and refetching better than a hand-rolled store.
Choosing where state lives
Server data (API responses) is a separate concern: a caching query layer usually beats storing it in a global store by hand.
effectScope captures the reactive effects (computeds, watchers) created inside it so you can dispose of them all at once with scope.stop(). Composables used outside a component (in a store, a plugin, or shared singleton logic) don't get automatic cleanup, and effectScope provides it.
It's an advanced tool: most component-scoped effects are cleaned up automatically on unmount. You reach for effectScope when reactive logic lives outside a component's lifecycle and would otherwise leak watchers.
import { effectScope, watch } from 'vue'
const scope = effectScope()
scope.run(() => {
watch(source, handler) // tracked by the scope
})
scope.stop() // disposes every effect created insideVue 3 split its runtime into a platform-agnostic core and a renderer. createRenderer lets you target something other than the browser DOM: native mobile, canvas, terminal, or a game engine, by supplying platform-specific create, patch, and remove operations.
You almost never write one directly, but it's why projects can render Vue components to non-DOM targets, and knowing the runtime is decoupled from the DOM is the architectural point interviewers probe.
The simple pattern is fetch in onMounted into a ref, tracking loading and error state by hand. It works but you reimplement caching, refetching, and race handling in every component. A query library (TanStack Query for Vue) handles caching, deduplication, background refetch, and stale-while-revalidate for you.
For SSR apps, fetching moves into the framework's data layer (Nuxt's useFetch or useAsyncData) so it runs on the server for the first render and hydrates without a second request. Matching the pattern to the app is the judgment being tested.
Vue escapes interpolated content by default, so {{ }} is safe against XSS. The danger is v-html, which renders raw HTML: never pass user-controlled content to it without sanitizing, or you open a script-injection hole. The same care applies to binding user input into URLs or href attributes.
Beyond rendering: keep secrets out of the client bundle (anything shipped to the browser is public), validate on the server not just in the form, and set a Content Security Policy. Client-side checks are UX, not security.
Key point: Naming v-html as the specific XSS vector, and 'client checks are UX not security', is what separates a real answer from a generic one.
Design components to be controlled and flexible: expose behavior through props and events, use slots (especially scoped slots) so consumers control markup, and avoid baking in app-specific assumptions. Forward attributes and listeners so a base input behaves like a native one, and support v-model where two-way binding is expected.
Ship with clear TypeScript types, theming via CSS variables rather than hard-coded styles, and per-component imports so consumers tree-shake. Document the contract and version breaking changes carefully, because a library's API is a promise to every consumer.
A hydration mismatch happens when the HTML the server rendered doesn't match what the client would render on first pass, so Vue can't cleanly attach event listeners and either warns or discards and re-renders that subtree. Common causes: rendering the current time or random values, reading window or localStorage during render, and invalid HTML nesting the browser silently fixes.
The fix is to make the first client render deterministic and identical to the server's: move browser-only logic into onMounted, guard access to browser globals, and use a client-only wrapper for content that genuinely can't render on the server.
A plugin is an object with an install function (or a plain function) that receives the app instance. Inside, it can register global components and directives, add global properties, provide values, or configure the app. You activate it with app.use(plugin, options).
Vue Router, Pinia, and i18n are all plugins. Writing one is how you package cross-cutting setup so an app enables it in a single line rather than wiring it in many places.
const myPlugin = {
install(app, options) {
app.provide('config', options)
app.directive('focus', { mounted: el => el.focus() })
},
}
// main.js: app.use(myPlugin, { theme: 'dark' })v-memo memoizes a subtree of the template: you give it an array of dependencies, and Vue skips re-rendering that subtree unless one of those values changed. It's a manual escape hatch for cases where Vue's own diffing still costs too much.
Its main payoff is large lists where each row is expensive and only a few rows change. Reach for it only after profiling shows the render is the bottleneck, because a wrong dependency array causes stale UI that's hard to trace.
<div v-for="item in list" :key="item.id"
v-memo="[item.id === selectedId]">
<!-- re-renders only when its selected state flips -->
</div>Key point: The trap answer is using v-memo everywhere. The right coverage names it as a last-resort, post-profiling tool for large lists, with the stale-UI risk.
By default watchers run before Vue updates the DOM (pre flush), so if a watcher reads the DOM it sees the old state. Setting flush: 'post' runs the watcher after the DOM update, which you need when the callback measures or manipulates the freshly rendered DOM.
There's also flush: 'sync' that fires synchronously on every change, rarely wanted because it can run many times per tick and hurt performance. Knowing pre versus post is what prevents 'my watcher sees stale DOM' bugs.
import { watch, ref } from 'vue'
const items = ref([])
watch(items, () => {
measureLayout() // reads updated DOM
}, { flush: 'post' }) // run after DOM patchVue sits between React's minimal core and Angular's batteries-included framework: it gives you an official router, state library, and build tooling without forcing a rigid structure. It picks a gentle learning curve and single-file components as its main bet, so teams get productive fast on readable code. Where it competes hardest is against React for market share and against Svelte for compiled output size. Knowing these trade-offs out loud is itself an interview signal: it shows you pick tools on merits, not habit.
| Framework | Reactivity model | Best at | Watch out for |
|---|---|---|---|
| Vue | Proxy-based, automatic tracking | Fast onboarding, single-file components | Smaller hiring pool than React |
| React | Explicit re-render, hooks | Huge ecosystem and job market | More boilerplate, manual memoization |
| Angular | Zone.js / signals | Large enterprise apps, opinionated | Steep curve, heavier framework |
| Svelte | Compile-time reactivity | Small bundles, no virtual DOM | Smaller ecosystem, fewer libraries |
Prepare in layers, and practice out loud. Most Vue rounds move from concept questions to live coding to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical Vue interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Vue questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works