Vue.js Interview Questions and Answers

Last updated:

Check out 60 of the most common Vue.js interview questions, then take an AI-powered practice interview

JavaScriptVuexNuxt.jsVue RouterComposition API
60+
Questions
24
Basic
24
Intermediate
12
Advanced
Q1

How does Vue 3's Proxy-based reactivity differ from Vue 2's Object.defineProperty approach?

BasicFundamentals

Answer

Vue 2 implemented reactivity by walking every data object at initialisation and converting each property into a getter/setter pair with Object.defineProperty. That design had well-known holes: adding or deleting a property after creation was invisible to the system (hence Vue.set and Vue.delete), assigning to an array index or changing length did not trigger updates (Vue patched seven array methods to compensate), and Map or Set could not be made reactive at all. Vue 3 replaced this with an ES2015 Proxy around the whole object.

A Proxy intercepts get, set, has, and deleteProperty operations, so property addition, deletion, index assignment, and even the in operator are tracked without special-case APIs. Conversion is also lazy: nested objects are only wrapped when first accessed, which makes creating large reactive structures cheaper than Vue 2's eager recursive walk. Two consequences matter in interviews.

First, a Proxy cannot wrap a primitive, which is exactly why ref exists: it stores the primitive on a .value property of an object the reactivity system can intercept. Second, the proxy is not identity-equal to the original object: reactive(obj) !== obj, so mixing raw and proxied references (storing the raw object as a Map key and later looking it up through the proxy) silently fails. Use toRaw when you need the original and markRaw to exclude an object (a chart instance, a third-party SDK client) from proxying entirely. The Proxy approach is also why Vue 3 dropped IE11 support: Proxies cannot be polyfilled.

Key Points

  • Vue 2: per-property getters/setters, needed Vue.set / Vue.delete
  • Vue 3: Proxy traps get/set/has/deleteProperty, arrays and Map/Set just work
  • Lazy deep conversion makes large reactive objects cheaper to create
  • reactive(obj) !== obj: proxy identity is a real production gotcha
  • ref exists because primitives cannot be wrapped in a Proxy
Q2

ref vs reactive: which should be your default, and what breaks with each?

BasicReactivity

Answer

ref wraps any value (primitive or object) in an object with a .value property; reactive wraps only objects in a Proxy and gives you direct property access without .value. The official style guide and most large codebases (GitLab's frontend documentation, for example) now recommend ref as the default, because reactive has two structural weaknesses. First, you cannot replace a reactive object wholesale: state = { ...newState } destroys the proxy reference every consumer holds, whereas state.value = newState on a ref works fine.

Second, destructuring a reactive object copies plain values and severs reactivity: const { count } = reactiveState gives you a dead number. ref has its own friction: you must remember .value in script code (templates auto-unwrap top-level refs), and a ref nested inside a reactive object is auto-unwrapped, which surprises people reading the code. Interviewers commonly probe the edge cases: refs inside arrays or Map values are NOT unwrapped; auto-unwrap in templates only applies to top-level bindings, so obj.someRef in a template still needs .value if obj is a plain object. A practical rule that lands well in interviews: use ref for everything by default, use reactive only for a group of related fields you will never reassign (like a form model you mutate field by field), and never destructure reactive state without toRefs.

import { ref, reactive, toRefs } from 'vue';

const count = ref(0);
count.value++; // .value required in script

const form = reactive({ name: '', city: 'Pune' });
form.name = 'Asha'; // direct mutation, no .value

// BROKEN: destructuring severs reactivity
const { city } = form; // city is now a plain string

// FIX: toRefs keeps the link back to the proxy
const { name } = toRefs(form);
name.value = 'Ravi'; // updates form.name

// BROKEN: wholesale replacement kills the proxy
// form = reactive({ name: 'x', city: 'y' }); // consumers keep the old proxy

// refs handle replacement cleanly
const user = ref({ id: 1 });
user.value = { id: 2 }; // all watchers fire
💡 Pro Tip: If an interviewer asks 'ref or reactive?', pick ref as the default and immediately name the two reactive failure modes (reassignment and destructuring). That one answer signals real production experience.
Q3

What does <script setup> actually do at compile time, and how is it different from the setup() function?

BasicSingle File Components

Answer

<script setup> is compile-time syntactic sugar processed by the SFC compiler (@vue/compiler-sfc). The entire block becomes the body of the component's setup() function, and every top-level binding (variables, functions, imports) is automatically exposed to the template, no return statement needed. Compiler macros like defineProps, defineEmits, defineModel, defineExpose, and defineOptions are compiled away entirely: they are not runtime functions, need no import, and calling them anywhere except the top level of <script setup> is a compile error.

There are real differences beyond ergonomics. The template is compiled into a render function in the same scope as the script, so the generated code accesses variables directly instead of going through a render proxy with property lookups, which measurably improves render performance. Imported components and even imported utility functions are usable in the template directly.

Components authored with <script setup> are also closed by default: a parent holding a template ref to the component cannot reach its internals unless the child explicitly calls defineExpose. With the plain setup() option you return an object for the template, receive props and context (attrs, slots, emit, expose) as arguments, and everything you return is publicly reachable. Interviewers like to check whether you know await works at the top level of <script setup> (it makes the component an async component that requires Suspense) and that a second plain <script> block can coexist for module-level side effects or named exports.

<script setup lang="ts">
import { ref, computed } from 'vue';
import UserBadge from './UserBadge.vue'; // usable in template directly

const props = defineProps<{ userId: number }>(); // macro, no import
const emit = defineEmits<{ close: [] }>();

const visits = ref(0);
const label = computed(() => 'User #' + props.userId);

function handleClose() {
  emit('close');
}

// Only what you expose is reachable via template refs
defineExpose({ visits });
</script>

<template>
  <UserBadge :label="label" @click="handleClose" />
</template>
Q4

computed vs methods vs watchers: how do you decide which one to use?

BasicReactivity

Answer

computed is for derived state: it tracks its reactive dependencies during evaluation and caches the result until one of them changes. Calling a method from a template re-executes on every render of that component, even when nothing relevant changed, so an expensive filter inside a method is a genuine performance bug while the same logic in a computed runs only when its inputs change. Watchers (watch / watchEffect) are for side effects in response to state changes: firing an API call, writing to localStorage, imperatively controlling a third-party library.

The classic interview trap is using a watcher to keep one piece of state in sync with another, for example watching firstName and lastName to update a fullName ref. That is derived state wearing a side-effect costume: it adds an extra tick of staleness, an extra source of truth, and a place for bugs to hide. Model it as a computed instead.

The reverse mistake also exists: performing side effects inside a computed getter (mutating state, calling APIs). Computed getters must be pure; Vue may re-evaluate them at times you do not control, and since 3.4 the scheduler only notifies dependents when a computed's value actually changes, so hidden side effects can simply not run when you expected. Also know that computed can be writable: pass { get, set } and the setter typically decomposes the value back into its sources, a pattern used for v-model on wrapper components. In recent versions you can attach onTrack and onTrigger debug hooks in development to see exactly why a computed re-evaluated.

import { ref, computed, watch } from 'vue';

const first = ref('Priya');
const last = ref('Sharma');

// CORRECT: derived state as computed (cached, always consistent)
const fullName = computed(() => first.value + ' ' + last.value);

// Writable computed: decomposes back to sources
const editableName = computed({
  get: () => fullName.value,
  set(v: string) {
    [first.value, last.value = ''] = v.split(' ');
  },
});

// WRONG: watcher-as-derived-state (stale for one tick, duplicated truth)
// const fullNameBad = ref('');
// watch([first, last], () => { fullNameBad.value = first.value + ' ' + last.value; });

// CORRECT watcher use: a side effect
watch(fullName, (name) => localStorage.setItem('lastUser', name));
Q5

v-if vs v-show: what does each compile to, and when is each the right choice?

BasicTemplates

Answer

v-if is real conditional rendering: the compiler wraps the branch in a condition inside the render function, so when it is false the element and its entire component subtree are never created, and when it flips to false later everything is unmounted, running onUnmounted hooks, tearing down watchers, and freeing state. It is lazy (an initially-false branch costs nothing on first render) and it supports v-else-if and v-else chains, including on <template> wrappers that group multiple elements without adding a DOM node. v-show always renders the element and merely toggles the inline CSS display property, so the component behind it stays mounted with all its state alive; the toggle itself is nearly free because no mounting or patching happens. The decision is a cost trade-off: v-if has a higher toggle cost and zero hidden cost; v-show has a higher initial cost and near-zero toggle cost.

Use v-show for things toggled frequently in a session, dropdown panels, tab-like togglers, tooltips. Use v-if for branches that are rarely shown, expensive to mount, or must not run in certain states, for example a component that opens a WebSocket in onMounted, or content gated behind a permission check where merely mounting it would fire unwanted API calls. Two details interviewers check: v-show does not work on <template> (there is no element to apply display to) and does not pair with v-else; and combining v-if with v-for on the same element is an anti-pattern because v-if evaluates first in Vue 3 and cannot see the loop variable, so filter in a computed instead.

<template>
  <!-- Rarely shown, expensive subtree: v-if keeps it unmounted -->
  <AdminPanel v-if="user.role === 'admin'" />

  <!-- Toggled constantly: v-show avoids mount/unmount churn -->
  <FilterDrawer v-show="drawerOpen" />

  <!-- Chains and template grouping only work with v-if -->
  <template v-if="status === 'loading'"><Spinner /></template>
  <template v-else-if="status === 'error'"><RetryBanner /></template>
  <template v-else><ResultsList :items="items" /></template>

  <!-- ANTI-PATTERN: v-if with v-for on one element; filter in a computed -->
  <li v-for="job in activeJobs" :key="job.id">{{ job.title }}</li>
</template>
Q6

Why does v-for need :key, and why is using the array index a bug waiting to happen?

BasicTemplates

Answer

When a list re-renders, Vue diffs the new children against the old ones. Without keys it patches in place: the first old node is updated to look like the first new item, the second like the second, and so on. With keys, Vue matches nodes by identity, so it can reorder existing DOM nodes and component instances instead of rewriting their contents.

The failure mode shows up with stateful children. Imagine a v-for of comment components, each holding a draft reply in local state, keyed by index. Delete the first comment and every index shifts by one: Vue patches each existing instance to display the next comment's props, but the local draft state stays with the instance, so every draft is now attached to the wrong comment.

The same corruption hits uncontrolled form inputs, <transition-group> animations, and components with expensive onMounted work. Index keys are only acceptable for lists that are append-only and never reordered or filtered, and even then a stable id is safer because requirements change. Keys must be unique among siblings and stable across renders: do not use Math.random() (forces a full remount every render) and do not use the object itself. One more pattern worth naming in an interview: key on a single element or component is also a deliberate remount trigger, for example <UserProfile :key="route.params.id" /> forces a fresh instance when navigating between two users so stale state from the previous user cannot leak.

<template>
  <!-- CORRECT: stable identity from your data -->
  <CommentCard
    v-for="c in comments"
    :key="c.id"
    :comment="c"
  />

  <!-- BUG: index keys + deletion = local state attaches to wrong item -->
  <!-- <CommentCard v-for="(c, i) in comments" :key="i" :comment="c" /> -->

  <!-- key as a remount trigger: new id = fresh component instance -->
  <UserProfile :key="route.params.id" />
</template>

Key Points

  • Keys let the patch algorithm move nodes instead of rewriting them
  • Index keys corrupt local state when items are inserted, removed, or sorted
  • Keys must be stable and unique among siblings; never Math.random()
  • :key on a single component is an intentional remount switch
Q7

How do defineProps and defineEmits work, and what does one-way data flow mean in practice?

BasicComponents

Answer

defineProps and defineEmits are compiler macros available only inside <script setup>. They come in two flavours: runtime declaration (pass an object with types, required flags, defaults, and validator functions, which Vue checks in development and warns about in the console) and type-based declaration (pass a TypeScript generic; the compiler generates the equivalent runtime declaration from the types). The type-based form is standard in 2026 codebases because you get editor completion and vue-tsc checking for free. defineEmits similarly declares which events the component emits, with typed payloads in the tuple syntax introduced in Vue 3.3: defineEmits<{ save: [payload: Draft]; close: [] }>.

Declaring emits is not just documentation: undeclared events fall through as native listeners via attribute fallthrough, which can cause a click handler to fire twice (once from your emit, once from the native click on the root element). One-way data flow means props go down and events go up: a child never assigns to a prop. Vue warns loudly if you try, but the sneaky version is mutating a nested property of an object prop, which Vue cannot detect because the object is passed by reference.

It works, silently couples child to parent, and is the kind of thing interviewers ask you to spot in a code review exercise. The correct patterns are: emit an event and let the owner mutate, take the prop as the initial value for local state (knowing it will not track later prop changes), or derive a computed from it. Props are normalised to camelCase in script while templates conventionally use kebab-case attributes.

<script setup lang="ts">
interface Job {
  id: number;
  title: string;
  ctcLpa: number;
}

const props = defineProps<{
  job: Job;
  highlighted?: boolean;
}>();

const emit = defineEmits<{
  apply: [jobId: number];
  bookmark: [jobId: number, on: boolean];
}>();

function onApply() {
  // NEVER: props.job.title = 'edited' (silent parent mutation)
  emit('apply', props.job.id);
}
</script>

<template>
  <article :class="{ highlighted }">
    <h3>{{ job.title }} ({{ job.ctcLpa }} LPA)</h3>
    <button @click="onApply">Apply</button>
  </article>
</template>
Q8

How does v-model work on components, and what did defineModel change in Vue 3.4?

BasicComponents

Answer

On a native element, v-model compiles to a value binding plus the right event for the element type: input elements use value + input, checkboxes use checked + change, and select uses the selected option + change. On a component, v-model="x" is sugar for :modelValue="x" @update:modelValue="v => x = v". Before 3.4, implementing the child side meant declaring a modelValue prop, declaring an update:modelValue emit, and wiring both to your internal input, tedious boilerplate that every Vue developer typed hundreds of times.

Vue 3.4 stabilised defineModel, a macro that declares the prop and emit pair for you and returns a writable ref: reading it reads the prop, assigning to it emits the update event. The parent still owns the state, so one-way data flow is preserved; the ergonomics just stop fighting you. Components can expose multiple models with arguments, v-model:title and v-model:content on an editor component compile to title/update:title and content/update:content pairs, declared in the child as defineModel('title').

Native modifiers .lazy, .number, and .trim work on form elements, and custom components can receive their own modifiers: defineModel can return a [model, modifiers] pair when you pass options, letting a child implement something like v-model.capitalize. Interviewers often ask what happens when the parent does not bind a v-model at all: defineModel still works as local state (backed by a local ref), and you can pass { required: true } or { default: ... } options to control that contract.

<!-- Child: SalaryInput.vue -->
<script setup lang="ts">
// One macro replaces the prop + emit boilerplate (Vue 3.4+)
const amount = defineModel<number>({ default: 0 });
const currency = defineModel<string>('currency', { default: 'INR' });
</script>

<template>
  <input type="number" v-model.number="amount" />
  <select v-model="currency">
    <option>INR</option>
    <option>USD</option>
  </select>
</template>

<!-- Parent -->
<!--
<SalaryInput v-model="pkg.amount" v-model:currency="pkg.currency" />
equivalent to:
<SalaryInput
  :modelValue="pkg.amount" @update:modelValue="v => pkg.amount = v"
  :currency="pkg.currency" @update:currency="v => pkg.currency = v"
/>
-->
💡 Pro Tip: If asked about v-model, volunteer the compile target (:modelValue + @update:modelValue). Many candidates can use v-model but cannot explain what it desugars to, and that distinction is exactly what the question is testing.
Q9

Explain slots and scoped slots. When does a child need to pass data back up into the parent's template?

BasicComponents

Answer

Slots are Vue's content projection mechanism: the parent supplies template content, the child decides where it renders via <slot> outlets. A single default slot covers the common case; named slots (<slot name="header"> in the child, <template #header> in the parent) let a layout component expose multiple insertion points. Content inside the child's <slot> tags acts as fallback, rendered only when the parent provides nothing.

The important compile-time rule is scoping: slot content is compiled in the parent's scope, so it can see the parent's data but not the child's. Scoped slots exist precisely to cross that boundary in a controlled way: the child binds props onto the slot outlet (<slot :item="item" :index="i">), and the parent receives them with v-slot, typically destructured: <template #default="{ item }">. This is the foundation of the renderless component pattern: a component that owns behaviour (fetching, pagination, drag state, virtualized windowing) but delegates all markup to the consumer through scoped slots.

Libraries like Headless UI and many data-table components are built this way, and it remains the idiomatic answer when an interviewer asks how you would build a reusable list component whose row markup varies per usage. Details worth knowing: #default is the explicit name for the default slot; you cannot mix an implicit default with named templates without wrapping; $slots gives programmatic access in render functions; and checking whether a slot was provided ($slots.header) lets you conditionally render wrapper markup, avoiding empty styled containers.

<!-- Child: PaginatedList.vue (renderless-ish) -->
<script setup lang="ts">
const props = defineProps<{ items: any[]; pageSize: number }>();
import { ref, computed } from 'vue';
const page = ref(0);
const visible = computed(() =>
  props.items.slice(page.value * props.pageSize, (page.value + 1) * props.pageSize),
);
</script>

<template>
  <ul>
    <li v-for="(item, i) in visible" :key="item.id">
      <!-- child passes data UP into parent-authored markup -->
      <slot :item="item" :index="i">{{ item.id }}</slot>
    </li>
  </ul>
  <button @click="page++">Next</button>
</template>

<!-- Parent -->
<!--
<PaginatedList :items="jobs" :page-size="10">
  <template #default="{ item }">
    <strong>{{ item.title }}</strong> in {{ item.city }}
  </template>
</PaginatedList>
-->
Q10

Walk through the Composition API lifecycle hooks. Where did created and beforeCreate go?

BasicLifecycle

Answer

In the Composition API, lifecycle hooks are functions you import and call synchronously inside setup: onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted, plus onActivated and onDeactivated for components inside KeepAlive, onErrorCaptured for descendant errors, and onServerPrefetch for SSR data loading. beforeCreate and created have no Composition API equivalents because setup itself runs at that point in the component's life: any code you write directly in <script setup> executes where created logic used to live, before the component has a DOM. That framing usually satisfies the interviewer, but the follow-ups are where candidates separate. onMounted is where DOM-dependent work belongs (measuring elements, attaching third-party widgets, IntersectionObserver setup), and crucially it does not run during server-side rendering, which makes it the standard place to guard browser-only code in SSR apps. onUnmounted is your leak boundary: anything registered outside Vue's reactivity, window event listeners, setInterval timers, WebSocket connections, must be cleaned up here or it survives the component. Hooks must be registered synchronously during setup: if you call onMounted inside a setTimeout or after an await, there is no active component instance to attach it to, and Vue logs a warning while silently dropping the hook.

This synchronous-registration rule is also why composables that use lifecycle hooks must be called at the top level of setup, not conditionally or inside callbacks. Finally, onUpdated fires after any patch of the component and is a common source of accidental infinite loops when people mutate state inside it.

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const chartEl = ref(null);
let chart;
let resizeHandler;

onMounted(() => {
  // Browser-only: never runs during SSR
  chart = renderChart(chartEl.value);
  resizeHandler = () => chart.resize();
  window.addEventListener('resize', resizeHandler);
});

onUnmounted(() => {
  // The leak boundary: undo everything Vue does not manage
  window.removeEventListener('resize', resizeHandler);
  chart?.destroy();
});

// WRONG: async registration is silently dropped with a warning
// setTimeout(() => onMounted(() => {}), 0);
</script>
Q11

How do template refs work, and what does useTemplateRef from Vue 3.5 improve?

BasicDOM Access

Answer

A template ref gives you a direct handle on a DOM element or child component instance. Classic form: declare const inputEl = ref(null) in <script setup> and add ref="inputEl" to the element; Vue assigns the element into the ref after mount. That name-matching between a string attribute and a variable was always slightly magical, so Vue 3.5 added useTemplateRef('inputEl'), which makes the link explicit, plays better with dynamic ref names, and gives cleaner TypeScript inference.

Three behaviours get probed in interviews. Timing: the ref is null until the component mounts, so access it in onMounted or a watcher, never during setup body execution; and if the element sits behind v-if, the ref returns to null whenever the branch is false. Refs on components: with <script setup> children are closed by default, so parent code sees only what the child defineExposes; typing that on the parent side uses InstanceType<typeof Child> or the useTemplateRef generic.

Refs in v-for: the ref receives an array of elements, and its order is NOT guaranteed to match source order, so keying element handles by data id (often via a function ref, :ref="el => map.set(item.id, el)") is safer for anything order-sensitive. Template refs are the escape hatch for focus management, text selection, canvas and chart libraries, and integrating non-Vue widgets; reaching for them to read or write things Vue already renders declaratively is the anti-pattern interviewers watch for in code exercises.

<script setup lang="ts">
import { onMounted, watch } from 'vue';
import { useTemplateRef } from 'vue'; // Vue 3.5+

const searchInput = useTemplateRef<HTMLInputElement>('searchInput');

onMounted(() => {
  searchInput.value?.focus(); // null until mounted
});

const props = defineProps<{ editing: boolean }>();
watch(
  () => props.editing,
  async (on) => {
    if (on) searchInput.value?.select();
  },
);
</script>

<template>
  <input ref="searchInput" type="search" placeholder="Search jobs" />
</template>
Q12

What is nextTick, why does Vue need it, and when do you actually await it?

BasicReactivity

Answer

Vue batches DOM updates. When you mutate reactive state, the component is not re-rendered synchronously; instead its update job is queued and flushed on the next microtask. Mutate count ten times in one function and Vue renders once with the final value, which is a core performance property, not an implementation accident.

The consequence: immediately after a mutation, the DOM still shows the old state. nextTick() returns a promise that resolves after the pending update queue has flushed, so await nextTick() is the official way to run code against the updated DOM. Real cases where you need it: focusing an input that v-if just revealed, measuring an element's size after its content changed, scrolling a chat container to the bottom after appending a message, or triggering a third-party library to re-read the DOM. In component tests with Vue Test Utils the same rule appears constantly: after wrapper.setProps or a manual state change you await nextTick() (or the promise returned by trigger()) before asserting on rendered HTML.

What interviewers listen for: you should describe the microtask-based scheduler and batching, not just recite "wait for DOM update"; you should know updates are deduplicated per component; and you should mention the smell where code is littered with nextTick calls to paper over logic that should be a watcher with flush: 'post' or restructured to not read the DOM at all. Overuse of nextTick is usually a sign state and DOM are being kept in sync manually, which is Vue's job.

<script setup>
import { ref, nextTick } from 'vue';

const showReply = ref(false);
const replyBox = ref(null);
const messages = ref([]);
const listEl = ref(null);

async function openReply() {
  showReply.value = true;      // DOM not updated yet
  await nextTick();            // queue flushed, element exists now
  replyBox.value.focus();
}

async function addMessage(msg) {
  messages.value.push(msg);
  await nextTick();
  listEl.value.scrollTop = listEl.value.scrollHeight; // measure AFTER patch
}
</script>

<template>
  <ul ref="listEl"><li v-for="m in messages" :key="m.id">{{ m.text }}</li></ul>
  <textarea v-if="showReply" ref="replyBox" />
  <button @click="openReply">Reply</button>
</template>
Q13

What does <style scoped> actually do, and how do :deep(), :slotted(), and v-bind() in CSS work?

BasicSingle File Components

Answer

The SFC compiler implements scoped styles by generating a unique attribute like data-v-7ba5bd90, stamping it on every element rendered by the component's own template, and rewriting each selector to require it: .card becomes .card[data-v-7ba5bd90]. It is compile-time scoping, not shadow DOM, so it is an isolation convention rather than a hard boundary. The edges are where interviews go.

Child components receive the scope attribute only on their root element, so a scoped rule can style a child's root but nothing deeper; to intentionally reach inside a child (styling the internals of a third-party datepicker, for example) you wrap the inner part of the selector in :deep(), which compiles to [data-v-xxx] .inner-class. Slotted content is compiled in the parent's scope, so the child's scoped styles do not touch it unless the child opts in with :slotted(). :global() escapes scoping entirely for one selector, useful for body classes or overlay containers mounted via Teleport, which render outside the component's DOM subtree and therefore never carry your scope attribute (a very common real-world bug: scoped modal styles silently not applying to teleported content). Alternatives: <style module> gives CSS Modules with hashed class names exposed on $style, a stronger guarantee than attribute scoping. Finally, v-bind() in CSS lets a style block reference reactive script state, compiled to a CSS custom property updated at runtime, the clean way to drive a color or height from component state without inline styles.

<script setup>
import { ref } from 'vue';
const accent = ref('#0f766e');
</script>

<template>
  <div class="card">
    <DatePicker class="picker" />
    <slot />
  </div>
</template>

<style scoped>
.card {
  border-color: v-bind(accent); /* compiles to a CSS var driven by state */
}
/* Reach inside a child component deliberately */
.picker :deep(.dp-input) {
  font-family: inherit;
}
/* Style content the parent injected through the slot */
:slotted(p) {
  margin: 0;
}
/* Opt out for teleported/global elements */
:global(.app-modal-backdrop) {
  backdrop-filter: blur(4px);
}
</style>
Q14

Which event modifiers do you use in real applications, and what ordering pitfalls exist?

BasicTemplates

Answer

Event modifiers compile common addEventListener patterns into the template. @click.stop calls stopPropagation, .prevent calls preventDefault (the workhorse for form submits: @submit.prevent), .self fires the handler only when event.target is the element itself (the standard way to close a modal when the backdrop, not its children, is clicked), .capture registers in the capture phase, .once removes the listener after the first call, and .passive tells the browser the handler will not call preventDefault, which lets scroll and touch handlers run without blocking scrolling, a genuine mobile performance lever. Key modifiers filter keyboard events: @keyup.enter, @keydown.esc, plus system modifiers .ctrl, .shift, .alt, .meta, and .exact, which requires that only the listed modifiers are pressed. Without .exact, @click.ctrl also fires on ctrl+shift+click, a subtle bug in keyboard-shortcut-heavy admin UIs.

Two pitfalls get asked about specifically. First, order matters because modifiers compile into checks executed in sequence: @click.prevent.self prevents the default for any click that bubbles through the element, while @click.self.prevent only prevents it when the element itself was the target. Second, .passive and .prevent are contradictory; Vue warns if you combine them because a passive listener promising not to preventDefault cannot then do so. It is also worth saying in an interview that modifiers keep handlers pure: the method receives a clean event flow and stays free of DOM plumbing, which makes it directly unit-testable without simulating propagation details.

<template>
  <!-- Form submit without page reload -->
  <form @submit.prevent="onSubmit">
    <input @keyup.enter="onSubmit" @keydown.esc="reset" />
  </form>

  <!-- Backdrop click closes; clicks inside dialog do not -->
  <div class="backdrop" @click.self="close">
    <dialog open>...</dialog>
  </div>

  <!-- Ordering: .prevent.self vs .self.prevent behave differently -->
  <div @click.prevent.self="a">prevents all bubbled clicks</div>
  <div @click.self.prevent="b">prevents only own clicks</div>

  <!-- Shortcut that must NOT fire on ctrl+shift+s -->
  <button @click.ctrl.exact="save">Save (Ctrl+Click)</button>

  <!-- Non-blocking scroll handler on mobile -->
  <div @scroll.passive="onScroll">...</div>
</template>
Q15

watch vs watchEffect: how do they track dependencies differently, and where does each fail?

BasicReactivity

Answer

watch takes explicit sources (a ref, a getter function, or an array of them) and a callback receiving new and old values. It is lazy by default, firing only when a source changes, with opts for immediate: true (run once on creation), deep: true (traverse object mutations; since 3.5 deep also accepts a number to cap traversal depth), and once: true (added in 3.4, auto-stops after the first trigger). watchEffect runs its function immediately and automatically tracks every reactive value read synchronously during that run, re-running when any of them change; you get no old values and no explicit source list. The trade-off: watch gives precision, before/after comparison, and control over exactly what triggers it; watchEffect gives brevity for effects whose dependency set is obvious from the code.

Each has a characteristic failure. With watch, the classic mistake is passing a reactive property directly, watch(state.count, ...) passes a plain number and never fires; you need a getter, watch(() => state.count, ...). With watchEffect, dependencies read after an await are not tracked, because automatic tracking only spans the synchronous portion of the effect; an async watchEffect that reads a ref after fetching will silently stop reacting to it.

Both return a stop handle, and both auto-stop when the owning component unmounts as long as they were created synchronously inside setup. In interviews, the strongest answers also mention cleanup: the callback's onCleanup argument (or onWatcherCleanup in 3.5) runs before each re-run, which is where you abort stale fetches.

import { ref, reactive, watch, watchEffect } from 'vue';

const filters = reactive({ city: 'Bengaluru', minLpa: 8 });
const results = ref([]);

// WRONG: passes a plain number, never fires
// watch(filters.minLpa, refetch);

// CORRECT: getter source, with old/new comparison
watch(
  () => filters.minLpa,
  (next, prev) => console.log('minLpa', prev, '->', next),
);

// watchEffect: auto-tracks filters.city and filters.minLpa
const stop = watchEffect(async (onCleanup) => {
  const ctrl = new AbortController();
  onCleanup(() => ctrl.abort()); // cancel the stale request on re-run
  const q = `?city=${filters.city}&min=${filters.minLpa}`;
  results.value = await fetch('/api/jobs' + q, { signal: ctrl.signal })
    .then((r) => r.json());
  // NOTE: refs read down here (after await) would NOT be tracked
});
Q16

What happens in createApp, and how do you structure plugins with app.use?

BasicApplication Setup

Answer

createApp(RootComponent) constructs an isolated application instance; app.mount('#app') compiles/mounts the root component into the DOM and kicks off the reactive render loop. The isolation is the point: in Vue 2, Vue.component, Vue.mixin, and prototype patches mutated a global constructor shared by every app on the page (a real problem for micro-frontends and widget embeds); in Vue 3 each createApp carries its own registry of components (app.component), directives (app.directive), provided values (app.provide), and config. app.config is where cross-cutting behaviour lives: errorHandler (last-resort error sink, wire it to Sentry), warnHandler, performance = true (emits component timing marks visible in browser devtools performance traces), and globalProperties, the replacement for Vue 2 prototype patching, though in Composition API codebases provide/inject or plain imports are preferred because globalProperties are weakly typed and invisible to tree-shaking. A plugin is anything with an install(app, options) method (or a bare function); app.use calls it once, deduplicating repeat registrations.

Vue Router and Pinia are just plugins: router installs global components (RouterLink, RouterView) and provides the router instance; Pinia provides its root store container. Writing your own plugin is a standard interview task, typically an API client or feature-flag service: create the object, app.provide it under a Symbol key, optionally register a component or directive, and pair it with a small composable (useApi) so consumers never touch inject keys directly. Order matters in main.ts: register plugins before mount, and remember multiple apps can coexist on one page, each with independent config.

// plugins/api.ts
import type { App, InjectionKey } from 'vue';

export interface ApiClient { get(path: string): Promise<any> }
export const ApiKey: InjectionKey<ApiClient> = Symbol('api');

export const apiPlugin = {
  install(app: App, opts: { baseUrl: string }) {
    const client: ApiClient = {
      get: (p) => fetch(opts.baseUrl + p).then((r) => r.json()),
    };
    app.provide(ApiKey, client);
  },
};

// composables/useApi.ts
import { inject } from 'vue';
export function useApi() {
  const api = inject(ApiKey);
  if (!api) throw new Error('apiPlugin not installed');
  return api;
}

// main.ts
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);
app.config.errorHandler = (err, instance, info) => reportToSentry(err, info);
app.use(apiPlugin, { baseUrl: 'https://api.goodspace.ai' });
app.mount('#app');
Q17

Options API vs Composition API in 2026: what are the real differences, and can they coexist?

BasicFundamentals

Answer

The Options API organises a component by option type: data, computed, methods, watch, lifecycle hooks. The Composition API organises by logical concern: all the state, computed values, watchers, and lifecycle hooks for one feature sit together, and can be extracted into a composable function that any component calls. That extraction story is the decisive difference.

The Options API's reuse mechanism was mixins, which suffer from invisible property sources (which mixin did this.loadData come from?), naming collisions, and implicit ordering; composables make every dependency an explicit import and every returned binding an explicit variable. Composition also types dramatically better: TypeScript struggles with the this-based Options object, while composables are ordinary functions with ordinary inference. Both APIs sit on the same reactivity core, and they interoperate: an Options component can add a setup() option, and composable-produced state can back Options components during incremental migration.

Vue has committed to supporting the Options API indefinitely, and it remains genuinely fine for small components; but new code at serious Vue shops, and Vue's own documentation defaults, are Composition with <script setup>. In Indian interviews this question is often really a migration question: services companies maintaining Vue 2-era codebases want to hear a concrete strategy, keep existing Options components working, write all new components in <script setup>, extract shared mixin logic into composables one at a time, and resist big-bang rewrites. Saying "Composition is better" without acknowledging when Options is acceptable, or without a coexistence plan, reads as inexperience.

Key Points

  • Options groups by option type; Composition groups by feature concern
  • Composables replace mixins: explicit imports, no naming collisions
  • Composition API has far better TypeScript inference
  • Both share one reactivity core and can coexist via the setup() option
  • Migration answer interviewers want: incremental, new code first, no rewrites
Q18

How do you scaffold a Vue project in 2026, and why are Vue CLI and webpack no longer the answer?

BasicTooling

Answer

The official scaffold is create-vue, invoked with npm create vue@latest. It walks you through opt-ins, TypeScript, JSX, Vue Router, Pinia, Vitest, an e2e runner (Playwright or Cypress), ESLint, Prettier, and generates a Vite project. Vue CLI, the webpack-based tool that dominated the Vue 2 era, is in maintenance mode and should not start new projects; interviewers use this to date your experience.

The reason is Vite's dev model: instead of bundling the whole app before serving, Vite serves source files over native ES modules, pre-bundling only dependencies with esbuild, so cold starts stay fast regardless of app size and HMR updates are near-instant because only the changed module graph is invalidated. Production builds go through Rollup with sensible defaults: content-hashed filenames, CSS extraction, automatic chunk splitting for dynamic imports. The commands that matter day to day: npm run dev (dev server), npm run build (production build, and in TS templates the build script runs vue-tsc type-checking alongside), npm run preview (serve the built output locally, the correct way to sanity-check a production bundle before shipping), and npx vitest for tests.

Editor tooling is the Vue - Official VS Code extension (the project formerly known as Volar), which provides template type-checking, and vue-tsc is its CLI twin for CI. Worth mentioning for senior roles: the Vue plugin is @vitejs/plugin-vue, environment variables must be prefixed VITE_ to reach client code via import.meta.env, and rolldown, the Rust-based bundler effort from the Vite team, is progressively speeding up production builds in recent Vite versions.

# Scaffold (interactive prompts for TS, Router, Pinia, Vitest, e2e)
npm create vue@latest goodspace-web
cd goodspace-web && npm install

# Daily commands
npm run dev        # Vite dev server, native ESM + instant HMR
npm run build      # vue-tsc type-check + Rollup production build
npm run preview    # serve dist/ locally to verify the real bundle
npx vitest         # unit tests in watch mode

# CI type-check without emitting
npx vue-tsc --noEmit

# Env vars must be prefixed to be exposed to the client
# .env.production -> VITE_API_BASE=https://api.goodspace.ai
# usage: import.meta.env.VITE_API_BASE
Q19

When do you reach for provide/inject, and how do you keep injected state from becoming untraceable?

BasicComponents

Answer

provide/inject passes values from a component to any depth of its descendants without threading props through intermediate layers. The provider calls provide(key, value) in setup (or app.provide for app-wide services); any descendant calls inject(key). Its sweet spot is subtree-scoped context: a <Form> providing validation registration to nested <FormField>s, a <Tabs> component coordinating with <Tab> children, a theme object, a per-request locale in SSR.

It is not a general state store, cross-cutting app state that unrelated components read belongs in Pinia, where devtools, SSR serialization, and testability are solved for you. Untraceability is the real hazard: inject('config') three levels deep is invisible in the child's props, so keep it disciplined. Use Symbol keys exported from a shared module (typed with InjectionKey<T> so inject infers the type), wrap the pair in a composable (provideFormContext / useFormContext) so consumers never touch raw keys and the composable can throw a clear error when the provider is missing, and pass reactivity intentionally: provide the ref itself if descendants should react to changes, and wrap it in readonly() so children cannot mutate parent state, keeping one-way flow honest.

Mutations should travel back as provided functions (provide a register/update callback), mirroring props-down-events-up at subtree scale. Interviewers also check defaults: inject(key, fallback) avoids undefined explosions, and the third argument treats a function as a factory for lazily-constructed defaults.

// formContext.ts
import { provide, inject, readonly, ref, type InjectionKey, type Ref } from 'vue';

interface FormCtx {
  errors: Readonly<Ref<Record<string, string>>>;
  setError: (field: string, msg: string) => void;
}
const FormKey: InjectionKey<FormCtx> = Symbol('form');

export function provideFormContext() {
  const errors = ref<Record<string, string>>({});
  const setError = (field: string, msg: string) => {
    errors.value = { ...errors.value, [field]: msg };
  };
  // readonly ref: descendants can react but not mutate
  provide(FormKey, { errors: readonly(errors), setError });
  return { errors, setError };
}

export function useFormContext(): FormCtx {
  const ctx = inject(FormKey);
  if (!ctx) throw new Error('useFormContext must be used inside a <Form>');
  return ctx;
}
Q20

Why is v-html dangerous, and what is the correct way to render user-generated rich content?

BasicSecurity

Answer

v-html sets the element's innerHTML with the raw string, bypassing Vue's escaping entirely. Regular mustache interpolation ({{ text }}) is safe because Vue writes text nodes, so a payload like <img src=x onerror=alert(1)> renders as literal text; the same string through v-html executes. The threat model is stored XSS: user-generated content (job descriptions, chat messages, resume summaries, CMS fields) that one user writes and another user's browser renders.

Once script runs in your origin it can read localStorage tokens, ride session cookies, and act as the victim. The correct pattern for legitimate rich content is sanitization at render time with DOMPurify: sanitize immediately before v-html, ideally wrapped in a computed or a small SafeHtml component so there is exactly one place in the codebase where v-html appears, which makes the pattern reviewable and lintable (eslint-plugin-vue's no-v-html rule can then be disabled for that single component instead of sprinkled everywhere). Sanitizing at write time alone is weaker: your rules evolve, old stored data does not. Related holes interviewers expect you to volunteer: attribute bindings are escaped but not validated, so :href="userUrl" with a javascript: scheme is an XSS vector Vue will not stop, validate URL schemes yourself; never feed user input into anything that compiles templates at runtime, because a Vue template is executable code; and pair sanitization with a Content-Security-Policy so a bypass has a second fence to climb. "v-html is banned except through our sanitizing wrapper component" is the production answer.

<!-- SafeHtml.vue: the ONLY file in the repo allowed to use v-html -->
<script setup lang="ts">
import { computed } from 'vue';
import DOMPurify from 'dompurify';

const props = defineProps<{ html: string }>();

const clean = computed(() =>
  DOMPurify.sanitize(props.html, {
    ALLOWED_TAGS: ['p', 'b', 'i', 'ul', 'ol', 'li', 'a', 'br', 'strong', 'em'],
    ALLOWED_ATTR: ['href', 'rel', 'target'],
  }),
);
</script>

<template>
  <!-- eslint-disable-next-line vue/no-v-html -->
  <div class="rich-text" v-html="clean" />
</template>

<!-- Elsewhere: {{ userText }} is auto-escaped and safe.
     Also validate URL schemes on :href before binding user URLs. -->
Q21

How do dynamic components with <component :is> work, and what does KeepAlive add?

BasicComponents

Answer

<component :is="x"> renders whatever component x resolves to: a component options object or a component imported in <script setup> (passed by reference), or a string naming a globally registered component. In <script setup>, prefer binding the component object itself, string resolution only works for globally registered names, and shallowRef is the right container for a component reference because deeply proxying a component definition is wasted work (Vue even warns about performance when it detects reactive component objects). The canonical uses are tab interfaces, wizard steps, CMS-driven layouts where the backend names blocks, and polymorphic rendering like a notification list where each item type maps to a renderer.

By default, switching :is unmounts the outgoing component and destroys its state: type into a form on tab A, switch to tab B and back, and the form is blank. Wrapping the dynamic component in <KeepAlive> caches deactivated instances instead of destroying them, preserving both component state and DOM. Cached components fire onDeactivated/onActivated instead of unmount/mount cycles, which is where you pause and resume polling or subscriptions.

KeepAlive accepts include and exclude (matched against the component's name option, which <script setup> SFCs infer from the filename) to whitelist what gets cached, and max, an LRU cap after which the least-recently-used instance is actually destroyed. The interview follow-up is usually memory: caching heavy components (data grids, editors) indefinitely is a leak by design, so bound the cache with max and keep an eye on detached-DOM growth in devtools memory snapshots.

<script setup>
import { shallowRef } from 'vue';
import ProfileTab from './ProfileTab.vue';
import ApplicationsTab from './ApplicationsTab.vue';
import SettingsTab from './SettingsTab.vue';

const tabs = { ProfileTab, ApplicationsTab, SettingsTab };
// shallowRef: do not deeply proxy a component definition
const current = shallowRef(ProfileTab);
</script>

<template>
  <nav>
    <button v-for="(comp, name) in tabs" :key="name" @click="current = comp">
      {{ name }}
    </button>
  </nav>

  <!-- State survives tab switches; cap the cache to bound memory -->
  <KeepAlive :max="3" :exclude="['SettingsTab']">
    <component :is="current" />
  </KeepAlive>
</template>
Q22

How do you declare typed props with defaults in TypeScript, and what did reactive props destructure change in Vue 3.5?

BasicTypeScript

Answer

Type-based props declaration passes an interface to the defineProps generic; the SFC compiler generates the runtime props option from the types, so optional TS properties become non-required props and union types become runtime type lists where possible. Defaults were historically the awkward part: the runtime-declaration style embeds default in each prop's options object, but the type-based style needed withDefaults(defineProps<Props>(), {...}), a wrapper many teams found noisy, with the extra rule that object and array defaults must be factory functions to avoid shared mutable state across instances. Vue 3.5 stabilised reactive props destructure, which makes destructured defineProps bindings compile into full props access: const { pageSize = 20, city = 'Remote' } = defineProps<Props>() now gives you both destructuring ergonomics and JavaScript-native default values, and, critically, the destructured variables stay reactive because the compiler rewrites every use of pageSize into props.pageSize under the hood.

Before 3.5 that destructure produced dead, non-reactive constants, one of the most common Vue 3 bugs in the wild, so knowing which behaviour your project's Vue version gives you is a legitimate interview differentiator. One caveat survives the upgrade: passing a destructured prop into a watcher or composable by value (watch(pageSize, ...)) passes a snapshot; wrap it in a getter (watch(() => pageSize, ...)) so changes propagate. For component consumers, complex prop types (generics, external interfaces) import cleanly since Vue 3.3 removed the old restriction that the defineProps generic could only reference locally declared types.

<script setup lang="ts">
import { watch } from 'vue';
import type { JobFilter } from '@/types/jobs';

interface Props {
  filter: JobFilter;
  pageSize?: number;
  city?: string;
}

// Vue 3.5+: destructure keeps reactivity, plain JS defaults
const { filter, pageSize = 20, city = 'Remote' } = defineProps<Props>();

// Destructured props in watch sources need a getter wrapper
watch(
  () => pageSize,
  (n) => console.log('page size ->', n),
);

// Pre-3.5 equivalent:
// const props = withDefaults(defineProps<Props>(), {
//   pageSize: 20,
//   city: 'Remote',
// });
</script>
Q23

Vue 2 reached end of life at the end of 2023. What does that mean for teams still running it, and what are the key breaking changes on the way to Vue 3?

BasicVersions & Migration

Answer

Vue 2's end of life on December 31, 2023 means no official updates of any kind, including security patches, and the ecosystem has moved: current releases of Vue Router, Vuex/Pinia, Nuxt, Vite plugins, and devtools target Vue 3, so a Vue 2 app is progressively pinned to aging dependency versions with known CVE exposure. This matters in India specifically because services companies (TCS, Infosys, Accenture and their peers) maintain large Vue 2 estates for clients, so migration experience is a hireable skill in itself. Organisations that cannot migrate on schedule buy extended commercial support (HeroDevs' never-ending support for Vue 2 is the known vendor), but that is runway, not a destination.

The breaking changes you should be able to list cold: the global API moved from the mutable Vue constructor to per-app createApp instances; filters were removed (use computed or methods); $on/$off/$once were removed, killing the event-bus pattern (use an external emitter like mitt, or better, Pinia/provide-inject); v-model was overhauled (value/input became modelValue/update:modelValue, .sync merged into v-model arguments); v-if now takes precedence over v-for on the same element (reversed from Vue 2); functional components became plain functions; destroyed hooks were renamed to unmounted; and the reactivity caveats around Vue.set disappeared because of Proxies. Practical migration path: the @vue/compat build runs Vue 3 with Vue 2 behaviour flags and console warnings per deprecated usage, letting you burn down warnings incrementally; move Vuex to Pinia; swap Vetur for the Vue - Official extension; and migrate the build to Vite as a separate, earlier step since it derisks the rest.

Key Points

  • EOL Dec 31, 2023: no security patches; ecosystem now targets Vue 3
  • Removed: filters, $on/$off event bus, Vue.set (obsolete with Proxies)
  • v-model overhaul: modelValue + update:modelValue, .sync folded in
  • @vue/compat build enables incremental, warning-driven migration
  • Migration experience is directly marketable at Indian services firms
Q24

How do :class and :style bindings merge, and how does that interact with attribute fallthrough on components?

BasicTemplates

Answer

Vue extends class and style bindings beyond strings. :class accepts an object ({ active: isActive } includes the key when the value is truthy), an array (mixing static strings, conditionals, and nested objects), or any combination, and it always merges with the static class attribute on the same element rather than replacing it. :style accepts an object with camelCase or quoted kebab-case CSS properties, an array of such objects (merged left to right, later wins), and Vue automatically applies vendor prefixes at runtime when the current browser needs them; providing an array of values for one property (display: ['-webkit-box', 'flex']) makes the browser take the last value it supports. The part that separates candidates is fallthrough behaviour on components. class and style passed to a component are fallthrough attributes: on a single-root component they merge automatically onto the root element, so a design-system button with its own internal classes plus a consumer's class="mt-4" ends up with both. On a multi-root component there is no automatic target; Vue warns, and you must bind $attrs explicitly onto the element you choose.

Components can also opt out entirely with defineOptions({ inheritAttrs: false }) and then place $attrs deliberately, the standard technique for input wrappers where the consumer's classes belong on the outer div but attributes like placeholder and aria-* belong on the inner <input>, accessed granularly via useAttrs(). Knowing this pattern is effectively mandatory for design-system work, which is a large fraction of Vue jobs at Indian product companies.

<!-- BaseInput.vue: split fallthrough between wrapper and input -->
<script setup lang="ts">
defineOptions({ inheritAttrs: false });
defineProps<{ label: string; error?: string }>();
const model = defineModel<string>();
const baseStyle = { borderRadius: '8px' };
</script>

<template>
  <!-- consumer's class/style land on the wrapper -->
  <div class="field" :class="{ 'field--error': error }">
    <label>{{ label }}</label>
    <!-- everything else (placeholder, aria-*, data-*) lands on the input -->
    <input v-bind="$attrs" v-model="model" :style="[baseStyle, error && { borderColor: 'crimson' }]" />
    <small v-if="error">{{ error }}</small>
  </div>
</template>

<!-- Usage:
<BaseInput class="mt-4" label="Email" placeholder="you@example.com" />
-->
Q25

What are the ways reactivity gets lost in Vue 3, and how do toRef, toRefs, and toValue prevent it?

IntermediateReactivity

Answer

Reactivity in Vue 3 lives in the proxy or the ref container, not in the values themselves, so any operation that copies a value out severs the link. The catalogue: destructuring a reactive object (const { user } = store copies a snapshot for primitives); passing a reactive property as a plain function argument (fn(state.count) passes a number, and the function can never observe changes); spreading a reactive object ({ ...state } produces a dead plain object, a frequent bug when building request payloads that are then expected to stay in sync); and, before Vue 3.5, destructuring defineProps. The repair kit: toRefs(state) converts every property into a ref linked back to the source proxy, the standard last line of a composable that stores state in a reactive object but wants consumers to destructure safely. toRef(state, 'count') creates one linked ref, and works even for properties that do not exist yet. toValue (added in 3.3) is the composable author's normalizer: it unwraps a ref, calls a getter, or passes a plain value through, letting a composable accept MaybeRefOrGetter<T> arguments so callers can pass useFeature(flag), useFeature(() => props.flag), or useFeature(true) interchangeably; internally you pair toValue with watchEffect or watch(() => toValue(arg)) so the composable stays live for reactive inputs. Interviewers often finish with unref (like toValue minus getter support) and the inverse trap: assigning a ref into a reactive object auto-unwraps it, so state.someRef = myRef then reading state.someRef returns the inner value, not the ref.

import { reactive, toRef, toRefs, toValue, watchEffect } from 'vue';
import type { MaybeRefOrGetter } from 'vue';

const state = reactive({ q: '', city: 'Delhi NCR' });

// DEAD: snapshot copies
const { q } = state;            // plain string
const payload = { ...state };   // dead object

// LIVE: linked refs
const cityRef = toRef(state, 'city');
const { q: qRef } = toRefs(state); // safe destructure

// Composable that accepts value | ref | getter
function useSearch(query: MaybeRefOrGetter<string>) {
  watchEffect(() => {
    const current = toValue(query); // normalized every run
    console.log('searching:', current);
  });
}

useSearch(qRef);              // ref
useSearch(() => state.q);     // getter
useSearch('data engineer');   // static value
Q26

When do shallowRef and shallowReactive beat their deep counterparts, and how do triggerRef and markRaw fit in?

IntermediatePerformance

Answer

Deep reactivity converts every nested object on access and tracks every property read, which is exactly right for form models and UI state, and exactly wrong for large data payloads. A ref holding a 10,000-row API response proxies every row and cell the moment your template iterates it, burning memory and CPU for granularity you will never use if the data only changes by wholesale replacement. shallowRef makes only .value reassignment reactive: swap in a new array and everything updates, mutate a row in place and nothing does. That contract, immutable-style replacement, fits API responses, chart datasets, and anything you treat as read-only snapshots. shallowReactive is the sibling for objects: top-level properties are reactive, nested objects are left raw.

Escape hatches: triggerRef(state) force-fires effects depending on a shallowRef after you deliberately mutated its contents in place (useful when a big structure gets one small patch and re-cloning is too expensive); markRaw(obj) brands an object so it is never proxied anywhere, which is the correct treatment for third-party class instances, a chart instance, a map SDK, a WebSocket client, whose internals break or waste cycles under proxying (Vue itself warns when it detects a component definition made reactive, same category of mistake). Two cautions worth voicing: shallow reactivity is a contract with your future self, a teammate mutating nested data will see stale UI and file a confusing bug, so document it at the declaration site; and integrating external stores is the other big use, the useSyncExternalStore-style pattern in Vue wraps external snapshots in shallowRef and replaces them on subscription callbacks.

import { shallowRef, triggerRef, markRaw, onMounted } from 'vue';
import { Chart } from 'chart.js/auto';

// 10k rows: only .value swaps are tracked, rows stay raw
const rows = shallowRef<Job[]>([]);

async function refresh() {
  rows.value = await fetch('/api/jobs?limit=10000').then((r) => r.json());
}

function patchOneRow(i: number, title: string) {
  rows.value[i].title = title; // no effect fires...
  triggerRef(rows);            // ...until forced
}

// Third-party instance: exclude from proxying entirely
const chartRef = shallowRef<Chart | null>(null);
onMounted(() => {
  chartRef.value = markRaw(new Chart(canvasEl.value, config));
});
💡 Pro Tip: In performance interviews, name the contract out loud: shallowRef means 'replace, never mutate'. Candidates who mention triggerRef and markRaw as the escape hatches come across as having actually shipped large-list Vue apps.
Q27

What makes a well-designed composable? Write one and explain the rules it follows.

IntermediateComposables

Answer

A composable is a function using Vue's reactivity primitives to encapsulate stateful logic, named use* by convention. The rules that make one good: (1) Call it synchronously at the top level of setup (or inside another composable), never conditionally or in callbacks, because lifecycle registration (onMounted, onUnmounted) and effect ownership depend on an active component instance; this is the reason a composable can safely register listeners and trust they will be torn down. (2) Clean up after yourself: every addEventListener, setInterval, observer, or socket the composable opens must be removed in onUnmounted (or via onScopeDispose so the composable also works inside effectScope outside components). (3) Return an object of refs, not a reactive object, so callers can destructure without losing reactivity; state that belongs together internally can still live in one reactive object, converted with toRefs on the way out. (4) Accept flexible inputs: MaybeRefOrGetter parameters normalized with toValue keep the composable live when callers pass reactive sources. (5) Keep them focused and composed: useJobSearch can internally use useDebouncedRef and useAbortableFetch rather than becoming a monolith. Contrast with mixins lands well in interviews: composables have explicit inputs and outputs, no property-name collisions, and traceable origins.

Also mention VueUse, the de facto standard library of composables; in real teams the first question about any proposed composable is whether VueUse already ships it. Testing story: pure composables test as plain functions; lifecycle-dependent ones get mounted inside a throwaway host component in Vitest.

// useEventListener.ts: the canonical cleanup-correct composable
import { onMounted, onUnmounted, toValue, watch } from 'vue';
import type { MaybeRefOrGetter } from 'vue';

export function useEventListener<K extends keyof WindowEventMap>(
  target: MaybeRefOrGetter<EventTarget | null>,
  event: K,
  handler: (e: WindowEventMap[K]) => void,
) {
  let current: EventTarget | null = null;

  const attach = () => {
    current = toValue(target);
    current?.addEventListener(event, handler as EventListener);
  };
  const detach = () => current?.removeEventListener(event, handler as EventListener);

  onMounted(attach);
  watch(() => toValue(target), () => { detach(); attach(); });
  onUnmounted(detach); // rule 2: no leaks past unmount
}

// usage in any component:
// useEventListener(window, 'resize', onResize);
// useEventListener(dropZoneRef, 'dragover', onDrag);
Q28

Explain watcher flush timing: pre, post, and sync. Where do onWatcherCleanup and the pause/resume API from Vue 3.5 come in?

IntermediateReactivity

Answer

Every watcher callback is scheduled, and flush timing decides where in the update cycle it runs. The default, flush: 'pre', runs callbacks before the owning component re-renders; state is current but the DOM still shows the previous frame, which is correct for the majority of watchers that only touch state or fire requests, and it lets Vue batch multiple mutations into one callback run. flush: 'post' defers the callback until after the component's DOM has been patched, which is what you need when the watcher reads layout: measuring an element that grows with content, scrolling to a newly rendered item, syncing a canvas overlay. watchPostEffect is shorthand for watchEffect with post timing. flush: 'sync' fires synchronously on every single mutation with no batching: push 500 items in a loop and a sync watcher fires 500 times, so it is reserved for rare cases like integrating with non-Vue code that must observe every intermediate value; watchSyncEffect exists but treat it as a red flag in code review. Cleanup: the watcher callback receives onCleanup, and Vue 3.5 added the standalone onWatcherCleanup import; either registers a function that runs before the next callback execution and when the watcher stops.

That is the correct home for AbortController.abort() on in-flight requests, clearTimeout on debounces, and unsubscribing per-run listeners, the fix for the classic race where a slow response for an old filter overwrites results for the new one. Vue 3.5 also gave the returned handle pause() and resume() alongside stop(), so you can mute a watcher during a bulk import and re-enable it after, instead of the old boolean-guard hack.

import { ref, watch, watchPostEffect, onWatcherCleanup } from 'vue';

const query = ref('');
const results = ref([]);
const listEl = ref(null);

// pre (default): fire the request, abort the stale one
watch(query, async (q) => {
  const ctrl = new AbortController();
  onWatcherCleanup(() => ctrl.abort()); // Vue 3.5 standalone import
  const res = await fetch('/api/search?q=' + encodeURIComponent(q), {
    signal: ctrl.signal,
  });
  results.value = await res.json();
});

// post: DOM is patched, safe to measure/scroll
watchPostEffect(() => {
  if (results.value.length) {
    listEl.value?.firstElementChild?.scrollIntoView({ block: 'nearest' });
  }
});

// 3.5: mute during bulk operations
const handle = watch(results, syncToAnalytics, { deep: true });
handle.pause();
// ...bulk import...
handle.resume();
Q29

How do you structure a Pinia store, and why does destructuring one require storeToRefs?

IntermediateState Management

Answer

Pinia stores are defined with defineStore in two styles. Option stores mirror the classic shape: state as a function returning the initial object, getters as computed-like functions, actions as methods; they get $reset for free. Setup stores pass a function using plain Composition API: refs become state, computeds become getters, functions become actions, which allows watchers inside the store, composable reuse, and better TypeScript inference, and is what most new codebases choose.

Either way, a store instance is a reactive object, which sets up the interview's favourite trap: const { user, isLoggedIn } = useAuthStore() destructures snapshots and breaks reactivity exactly like destructuring any reactive object. storeToRefs(store) is the fix, creating linked refs for state and getters while deliberately skipping actions, which are plain functions and should be destructured directly. Practical patterns that signal production experience: one store per domain (auth, jobs, checkout) rather than one god store; $patch for grouped mutations (object form for shallow merges, function form when you need to mutate arrays) so devtools records one entry instead of five; $subscribe to persist slices to localStorage on change; $onAction to instrument actions with timing and error logging; and getters that take arguments by returning a function (getters caching still applies to the outer computation). Because Pinia stores are created lazily per application instance and registered against the active pinia, they are SSR-safe and trivially testable; calling a store outside setup (router guards, plain modules) works after the app installs Pinia, or explicitly by passing the pinia instance to the use function.

// stores/jobs.ts (setup-store style)
import { defineStore, storeToRefs } from 'pinia';
import { ref, computed } from 'vue';

export const useJobsStore = defineStore('jobs', () => {
  const items = ref<Job[]>([]);
  const city = ref('Bengaluru');
  const loading = ref(false);

  const inCity = computed(() =>
    items.value.filter((j) => j.city === city.value),
  );

  async function fetchJobs() {
    loading.value = true;
    try {
      items.value = await api.get('/jobs?city=' + city.value);
    } finally {
      loading.value = false;
    }
  }

  return { items, city, loading, inCity, fetchJobs };
});

// In a component:
// const store = useJobsStore();
// const { inCity, loading } = storeToRefs(store); // reactive
// const { fetchJobs } = store;                    // actions: plain destructure
Q30

Why did Pinia replace Vuex as the official recommendation, and how would you migrate a Vuex 4 codebase?

IntermediateState Management

Answer

Pinia is the officially recommended state management library for Vue 3, and Vuex sits in maintenance mode receiving no new features. The design deltas explain the switch. Vuex separated mutations (synchronous, committed) from actions (async, dispatched), a ceremony inherited from Flux-era debugging needs; Pinia deletes mutations entirely, actions mutate state directly, and devtools still tracks every change.

Vuex's string-based commit('module/SET_USER') API defeated TypeScript inference and refactoring tools; Pinia stores are plain typed objects, so store.setUser(u) autocompletes and type-errors like normal code. Vuex nested modules with the namespaced flag produced deep, stringly-referenced trees; Pinia is flat by design, stores are independent and simply import each other when needed. Pinia is also tiny (roughly 1.5 KB) and its modular stores tree-shake, unused stores never enter the bundle.

Migration is mechanical enough to describe step by step in an interview: (1) run both side by side, Pinia and Vuex coexist on one app instance, so you migrate store by store, not big-bang; (2) each Vuex module becomes a defineStore: state function carries over nearly verbatim, getters drop the state/getters positional arguments in favour of this or setup-store computeds, and each mutation+action pair collapses into a single action; (3) rootState and cross-module access become direct imports of the other store's use function; (4) plugins like persistence move to Pinia equivalents (pinia-plugin-persistedstate); (5) components swap mapState/mapActions helpers for the store instance plus storeToRefs, though Pinia ships mapState/mapActions compatibility helpers for Options API components to shrink the diff. The finish line question interviewers add: what stays in a store at all, server cache belongs in a data-fetching layer (TanStack Query's Vue adapter, or Nuxt's useAsyncData), and Pinia should hold genuine client state.

Q31

How do Vue Router navigation guards work, and how do you build a reliable auth guard?

IntermediateRouting

Answer

Vue Router 4 runs guards in a defined pipeline on every navigation: global beforeEach, then per-route beforeEnter on newly entered routes, then in-component guards (onBeforeRouteUpdate when a reused component's params change, onBeforeRouteLeave on the outgoing component), then global beforeResolve (after all async route components have loaded, the last gate before commit), and finally afterEach, which cannot cancel and is the right hook for analytics page views and document.title. Modern guards return values instead of calling next(): return true or undefined to allow, false to abort, or a route location to redirect; async guards just return a promise, and the router waits. The reliable auth guard has three parts people get wrong.

First, wait for auth to initialise: on hard refresh, the guard often runs before your session restore completes, so the guard must await an authReady promise (or the store's init action) instead of reading a not-yet-populated user and bouncing legitimate users to login. Second, drive protection from route meta (meta: { requiresAuth: true } on parent routes, checked via to.matched.some(...) or to.meta with route meta merging) rather than hardcoding path lists that rot. Third, prevent redirect loops: the login route itself must be exempt, and the redirect should carry the intended destination (query: { redirect: to.fullPath }) so login can resume the journey. onBeforeRouteLeave doubles as the unsaved-changes guard, returning false after a confirm dialog. One more production note: guards run on the client only in a plain SPA, so anything security-critical must also be enforced by the backend; route guards are UX, not access control.

// router/guards.ts
import { useAuthStore } from '@/stores/auth';

router.beforeEach(async (to) => {
  const auth = useAuthStore();
  await auth.ready; // don't decide before session restore finishes

  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return {
      name: 'login',
      query: { redirect: to.fullPath }, // resume after login
    };
  }
  if (to.name === 'login' && auth.isLoggedIn) {
    return { name: 'dashboard' }; // no loop: exempt + inverse redirect
  }
  return true;
});

router.afterEach((to) => {
  document.title = (to.meta.title as string) ?? 'GoodSpace';
  trackPageView(to.fullPath); // cannot cancel here, safe for analytics
});

// In an edit form component:
// onBeforeRouteLeave(() => dirty.value ? confirm('Discard changes?') : true);
Q32

How does route-level code splitting work with Vite, and how do you handle chunk load failures after a deploy?

IntermediateRouting

Answer

Passing a dynamic import as the route component, component: () => import('@/views/Reports.vue'), makes Vite/Rollup emit that view and its exclusive dependencies as a separate chunk, fetched only when the route is first visited. This is the single highest-leverage bundle optimisation in most Vue apps, because admin panels, settings screens, and report builders stop taxing the landing page. The router only resolves the component during navigation, and global beforeResolve fires after these async components load, which is why errors surface there.

Grouping is controllable: Rollup's output.manualChunks in vite.config.ts can co-locate rarely-changing vendors, and several routes can share one chunk by importing from a shared barrel, though over-aggressive manual chunking often regresses caching, so measure with a bundle visualizer before and after. The production failure interviewers want to hear about: stale-deploy chunk errors. Chunks are content-hashed, so after you deploy, a user with an old tab requests Reports-abc123.js which no longer exists, and the navigation fails with a dynamic import error (Vite surfaces the vite:preloadError event for exactly this).

The robust handling: listen for vite:preloadError or catch the failure via router.onError, detect the fetch/import failure, and do a full location.reload() onto the target URL so the fresh HTML references fresh chunk names; keep the previous deploy's assets available for a grace window on your CDN as well, which avoids the error for in-flight sessions entirely. Add loading UX with a top progress bar driven by beforeEach/afterEach rather than defineAsyncComponent wrappers, because the router has first-class handling for lazy route components and wrapping them in defineAsyncComponent is explicitly discouraged.

// router/index.ts
const routes = [
  { path: '/', component: () => import('@/views/Home.vue') },
  {
    path: '/reports',
    component: () => import('@/views/Reports.vue'), // separate chunk
    meta: { requiresAuth: true },
  },
];

// Stale-deploy recovery: old tab requests a chunk hash that is gone
window.addEventListener('vite:preloadError', () => {
  window.location.reload(); // fresh HTML -> fresh chunk names
});

router.onError((error, to) => {
  if (String(error?.message).includes('Failed to fetch dynamically imported module')) {
    window.location.assign(to.fullPath); // land on target with new assets
  }
});

// vite.config.ts: deliberate vendor grouping (measure before adopting)
// build: { rollupOptions: { output: { manualChunks: {
//   charts: ['chart.js'],
// } } } }
Q33

How does defineAsyncComponent work, and how is it different from lazy route components?

IntermediatePerformance

Answer

defineAsyncComponent wraps a loader function returning a dynamic import and produces a component that fetches its implementation only when it first needs to render. The options object is what separates a toy usage from a production one: loadingComponent with a delay (default 200ms, so fast loads never flash a spinner), errorComponent shown if the loader rejects, timeout to force the error state on hung networks, and onError, a callback receiving retry and fail functions, which lets you implement bounded retries for flaky mobile connections, a very real concern for Indian consumer traffic on spotty 4G. The loader result is cached: the component loads once per app, then behaves like a normal component.

Use it below the route level for heavyweight islands inside an already-loaded page: a rich text editor behind an "Edit" button, a charting dashboard tab, a video call widget, a map. Combine with v-if so the fetch does not even start until the user asks for the feature. The distinction interviewers press: route-level splitting should use a plain () => import() in the route config, not defineAsyncComponent, because Vue Router resolves async route components itself inside the navigation pipeline (guards wait for them, navigation failures handle their errors); wrapping route components in defineAsyncComponent detaches loading from navigation and is explicitly discouraged in the router docs. Also worth knowing: async components integrate with Suspense (the wrapper defers to Suspense's fallback instead of its own loadingComponent when an ancestor Suspense exists), and in Vue 3.5 SSR apps the same API gained hydration strategies (hydrateOnVisible and friends) for deferring hydration rather than fetching.

import { defineAsyncComponent } from 'vue';

const RichEditor = defineAsyncComponent({
  loader: () => import('@/components/RichEditor.vue'),
  loadingComponent: EditorSkeleton,
  delay: 200,          // don't flash the skeleton on fast loads
  errorComponent: EditorLoadFailed,
  timeout: 10000,
  onError(error, retry, fail, attempts) {
    // bounded retry for flaky networks
    if (attempts <= 3) retry();
    else fail();
  },
});

// Template: fetch starts only when the user opts in
// <RichEditor v-if="editing" v-model="draft" />

// Route-level: use a bare dynamic import, NOT defineAsyncComponent
// { path: '/editor', component: () => import('@/views/EditorPage.vue') }
Q34

What does <Suspense> do, how does async setup interact with it, and what are its limitations?

IntermediateComponents

Answer

Suspense coordinates async dependencies in a subtree so a page shows one coherent fallback instead of a patchwork of independent spinners. It has two slots: #default for the real content and #fallback for the pending state. Two things count as async dependencies: components whose setup is async, which includes any <script setup> containing top-level await, and components created via defineAsyncComponent.

When the default slot first renders, Suspense collects every async dependency in the subtree, shows the fallback, and swaps in the resolved tree only when all of them settle, firing @resolve, @pending, and @fallback events you can hook for instrumentation. The subtle mechanics: a component with top-level await suspends, and after the await resumes, Vue restores the component context, which is why lifecycle hooks registered after an await still work inside <script setup> when Suspense manages the tree. Errors from async setup do not render anywhere by themselves; you catch them with onErrorCaptured in the parent and typically flip to an error state manually.

On subsequent updates, when the default tree changes to a new async component, Suspense keeps showing the OLD content (not the fallback) until the new one resolves, and the timeout prop controls how long before the fallback appears anyway, behaviour designed for router transitions. Limitations to state honestly in an interview: Suspense is still documented as experimental (usable, and Nuxt builds on it, but the API could shift); there is no built-in retry or error slot, unlike React's error boundaries paired with Suspense; and sequencing multiple Suspense boundaries on one page requires care so critical content is not held hostage by a slow sidebar widget. The combination pattern with router and transition is well documented: RouterView's v-slot exposes the component, wrapped in Transition, wrapped in Suspense.

<!-- JobDetailPage.vue: async setup via top-level await -->
<script setup lang="ts">
import { useRoute } from 'vue-router';
const route = useRoute();
// suspends the component until data arrives
const job = await fetch('/api/jobs/' + route.params.id).then((r) => r.json());
</script>

<!-- Parent -->
<script setup>
import { ref, onErrorCaptured } from 'vue';
const error = ref(null);
onErrorCaptured((e) => {
  error.value = e; // async setup errors surface HERE, not in a slot
  return false;    // stop propagation
});
</script>

<template>
  <ErrorPanel v-if="error" :error="error" />
  <Suspense v-else :timeout="300" @resolve="trackLoaded">
    <JobDetailPage />
    <template #fallback>
      <JobDetailSkeleton />
    </template>
  </Suspense>
</template>
Q35

When do you need <Teleport>, and what changed with the defer prop in Vue 3.5?

IntermediateComponents

Answer

Teleport renders a portion of a component's template into a different place in the DOM while keeping it fully inside the component's logical tree: props, events, provide/inject, and devtools ownership all behave as if the content never moved. The problem it solves is CSS containment: a modal declared deep inside a card that sits in a container with overflow: hidden, a transform (which creates a new containing block and breaks position: fixed), or a low z-index stacking context will clip or stack wrongly no matter how you style it. Teleporting to body escapes every ancestor constraint, which is why modals, drawers, toasts, tooltips, and dropdown menus are the canonical users.

The to prop takes a CSS selector or an element, and disabled toggles teleporting dynamically (a tooltip that renders inline on mobile but teleports on desktop). Multiple Teleports to one target append in order, which conveniently stacks toasts. The classic pre-3.5 gotcha: the target had to exist before the Teleport mounted, so teleporting to a container rendered later by the same app (a layout's outlet div) crashed with a target-not-found warning.

Vue 3.5's defer prop fixes exactly this: a deferred Teleport waits until after the current render cycle to resolve its target, so it works when the target element is rendered after it. Two production notes that impress: scoped styles do not follow teleported content in the sense that the teleported subtree keeps the component's scope attribute, but ancestor-selector assumptions (.card .modal) break because the DOM ancestry changed, so style teleported UI standalone; and for SSR, teleports render into a separate buffer that the server framework must inject at the target, Nuxt handles body teleports for you.

<script setup>
import { ref } from 'vue';
const open = ref(false);
</script>

<template>
  <button @click="open = true">Report job</button>

  <!-- Escapes overflow/transform/z-index traps of every ancestor -->
  <Teleport to="body">
    <div v-if="open" class="backdrop" @click.self="open = false">
      <dialog open>
        <h2>Report this listing</h2>
        <button @click="open = false">Close</button>
      </dialog>
    </div>
  </Teleport>

  <!-- Vue 3.5: target is rendered later in this same app -->
  <Teleport defer to="#toast-outlet">
    <ToastStack />
  </Teleport>
  <div id="toast-outlet" />
</template>
Q36

How do <Transition> and <TransitionGroup> apply classes, and how does the FLIP move animation work?

IntermediateUI & Animation

Answer

Transition watches a single child being inserted or removed (via v-if/v-show or a changing key/component) and choreographs six classes: v-enter-from, v-enter-active, v-enter-to on the way in; v-leave-from, v-leave-active, v-leave-to on the way out. From-classes apply for one frame, active-classes span the whole animation carrying the transition property, and to-classes define the destination state; a name prop (name="fade") swaps the v- prefix for fade-. A Vue 2 migration detail that still catches people: v-enter was renamed v-enter-from.

Vue auto-detects animation end from transitionend/animationend, or you set :duration explicitly; type disambiguates when an element has both transitions and animations. mode="out-in" sequences leave-then-enter, the standard for swapping tab panels or route views so both states never occupy the layout simultaneously. JavaScript hooks (@enter, @leave, receiving the element and a done callback, with :css="false") integrate GSAP or the Web Animations API. appear runs the enter transition on first mount. TransitionGroup extends this to lists: it renders an optional wrapper via the tag prop, requires every child to be keyed, and adds the v-move class.

The move behaviour is FLIP (First, Last, Invert, Play): when items reorder, Vue records each element's old position, lets the DOM update to final positions, applies an inverting transform that makes elements appear at their old spots, then releases it so they glide to their new places under the v-move transition. The gotcha bank: leaving items must be position: absolute during leave for surrounding items to slide smoothly; display: inline elements do not transform (use inline-block); and reduced-motion users deserve an @media (prefers-reduced-motion) override, an accessibility point interviewers increasingly reward.

<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="job in sortedJobs" :key="job.id">{{ job.title }}</li>
  </TransitionGroup>
</template>

<style scoped>
.list-enter-from { opacity: 0; transform: translateY(12px); }
.list-enter-active, .list-leave-active { transition: all 0.25s ease; }
.list-leave-to { opacity: 0; }

/* FLIP: applied while items glide to reordered positions */
.list-move { transition: transform 0.25s ease; }

/* Removed items leave the flow so neighbours can slide */
.list-leave-active { position: absolute; }

@media (prefers-reduced-motion: reduce) {
  .list-move, .list-enter-active, .list-leave-active { transition: none; }
}
</style>
Q37

Write a custom directive. Which hooks exist in Vue 3, and when is a directive the wrong tool?

IntermediateDirectives

Answer

A custom directive packages direct DOM manipulation behind a v- attribute. Vue 3 renamed the hooks to mirror the component lifecycle: created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted (Vue 2's bind/inserted/componentUpdated are gone, a migration question in its own right). Each hook receives the element and a binding object carrying value, oldValue, arg (v-mydirective:argument), and modifiers (v-mydirective.lazy).

Registration is per-component (a plain object in <script setup>, where any camelCase variable starting with a lowercase v auto-registers, e.g. const vFocus = {...}) or global via app.directive('focus', {...}). Good directive candidates are behaviours that attach to arbitrary elements and touch the DOM imperatively: autofocus, click-outside, intersection-based lazy loading, permission-based element removal, tooltip attachment. The classic pair of examples is v-focus (trivial) and v-click-outside (real, because it must add a document listener in mounted and, critically, remove it in unmounted, making it a leak-awareness test).

When is a directive wrong? When the logic needs state, templates, or lifecycle beyond DOM fiddling, that is a component; when it needs reactivity plumbing and no specific element, that is a composable. The modern heuristic: composables have largely replaced directives for logic reuse, and directives survive only where the element handle is the essence. Two production notes: on components, a directive applies to the single root element and Vue warns on multi-root components, so directives on components are best avoided; and for SSR, a directive that must render server-side attributes implements getSSRProps, since normal hooks never run on the server.

// v-click-outside: the leak-awareness interview classic
import type { Directive } from 'vue';

type Handler = (e: MouseEvent) => void;
const listeners = new WeakMap<HTMLElement, Handler>();

export const vClickOutside: Directive<HTMLElement, () => void> = {
  mounted(el, binding) {
    const onDocClick: Handler = (e) => {
      if (!el.contains(e.target as Node)) binding.value();
    };
    listeners.set(el, onDocClick);
    document.addEventListener('click', onDocClick, true);
  },
  unmounted(el) {
    const h = listeners.get(el);
    if (h) document.removeEventListener('click', h, true); // no leak
    listeners.delete(el);
  },
};

// <script setup>: const vClickOutside = ... auto-registers
// <div v-click-outside="closeDropdown">...</div>
Q38

How do errors propagate in Vue 3, and how do you build an error boundary plus global reporting?

IntermediateError Handling

Answer

Vue routes errors from component render functions, watchers, lifecycle hooks, event handlers, and (in recent versions) async operations through a defined chain: first up through ancestor components' onErrorCaptured hooks, then to app.config.errorHandler as the final sink. onErrorCaptured receives the error, the component instance that produced it, and an info string identifying the source type (render, watcher callback, event handler, and so on). Returning false from it stops further propagation, which is the primitive you use to build an error boundary: a wrapper component that captures descendant errors, flips its template to a fallback UI with a retry button, and reports the error, so one crashing widget does not blank the entire dashboard. Vue has no built-in ErrorBoundary component, so writing this small wrapper is a standard interview exercise. app.config.errorHandler is where centralised reporting lives: wire it to Sentry or your logging endpoint with the component name and info string as context.

The honest limitations you should volunteer: errors thrown in code Vue does not invoke, setTimeout callbacks, raw promise chains without await in a tracked context, event listeners you attached manually, bypass this chain, so production apps also register window 'error' and 'unhandledrejection' listeners as the outermost net. Errors in async setup surface to the parent's onErrorCaptured when using Suspense. Two production refinements: deduplicate before reporting (a render error can fire repeatedly under re-render pressure), and have the boundary reset its error state when its inputs change (watch a resetKey prop) so navigation naturally recovers the UI. This layered story, boundary for UX, errorHandler for telemetry, window listeners for the leftovers, is the complete answer.

<!-- ErrorBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured, watch } from 'vue';

const props = defineProps<{ resetKey?: unknown }>();
const error = ref<Error | null>(null);

onErrorCaptured((err, instance, info) => {
  error.value = err as Error;
  report(err, { info, component: instance?.$options.name });
  return false; // contain it here
});

watch(() => props.resetKey, () => (error.value = null)); // recover on nav
</script>

<template>
  <div v-if="error" class="boundary-fallback">
    <p>This section failed to load.</p>
    <button @click="error = null">Retry</button>
  </div>
  <slot v-else />
</template>

<!-- main.ts: telemetry sink + the outermost nets -->
<!--
app.config.errorHandler = (err, instance, info) => sentry.capture(err, { info });
window.addEventListener('unhandledrejection', (e) => sentry.capture(e.reason));
-->
Q39

What do v-once and v-memo actually skip, and when is v-memo worth it?

IntermediatePerformance

Answer

Both are compiler-level directives that prune patching work. v-once renders its element or component subtree exactly once and then treats it as static: on every future re-render of the parent, the whole subtree is skipped during diffing, even if the data it displayed has changed. It suits genuinely immutable content, a rendered terms-of-service block, a snapshot timestamp, a static header inside a frequently updating component. v-memo="[a, b]" is conditional: on re-render, Vue shallow-compares the dependency array with its last value, and if every entry is the same, the entire subtree's virtual DOM re-creation AND patching are skipped, the old vnodes are reused wholesale. An empty array, v-memo="[]", behaves like v-once.

The designed use case, and effectively the only place it pays for itself, is inside large v-for lists where each row's rendering depends on a small slice of state: the docs' canonical example memoizes each row on [item.id === selectedId], so clicking a new selection re-renders exactly two rows (the newly selected and the deselected) instead of all ten thousand. That works because the memo condition flips only for those rows. When used with v-for, v-memo must sit on the same element as the v-for; using it on a child inside the loop does not do what people expect.

The traps: forgetting a dependency silently freezes UI (the row shows stale data with no warning, a debugging nightmare someone on the team will eventually inflict), overlapping it with v-for's own keyed diffing for trivial rows yields nothing measurable, and v-memo does not work inside v-for when you also need the skipped subtree to respond to deep mutations. The mature stance interviewers want: measure first with the profiler, reach for v-memo on hot lists only, and prefer restructuring (pagination, virtual scrolling, shallowRef data) before micro-memoization.

<template>
  <!-- Rendered once, never re-diffed, even if `signedAt` changes later -->
  <p v-once>Signed at: {{ signedAt }}</p>

  <!-- 10k rows: only rows whose memo array changes get re-rendered -->
  <div
    v-for="job in jobs"
    :key="job.id"
    v-memo="[job.id === selectedId]"
    :class="{ selected: job.id === selectedId }"
    @click="selectedId = job.id"
  >
    {{ job.title }} · {{ job.city }}
  </div>
  <!-- Clicking re-renders exactly 2 rows: newly selected + deselected.
       Trap: omit a dependency the row reads and it silently shows stale data. -->
</template>
Q40

A child needs to edit data it receives as a prop. What are the correct patterns, and which common ones are bugs?

IntermediateComponents

Answer

Vue enforces props-down-events-up, and this question is the standard way interviewers test whether you respect it under pressure. Direct assignment (props.value = x) triggers a console warning and fails; but the dangerous variant is mutating a nested property of an object prop (props.job.title = x), which Vue cannot warn about because objects pass by reference. It works, mutates the parent's state invisibly, breaks any parent that assumed ownership, and defeats devtools' ability to attribute the change; treat it as a review-blocking bug.

The legitimate patterns, in order of preference: (1) Full ownership stays up: child emits intent (emit('update', patch)), parent applies it; with defineModel this collapses to almost no code while keeping the parent authoritative. (2) Writable computed bridging: computed({ get: () => props.value, set: (v) => emit('update:value', v) }) lets the child's template use v-model on the bridge while every write still flows through the parent, the classic wrapper-input pattern before defineModel and still what defineModel compiles down to conceptually. (3) Prop as initial value: const draft = ref(props.initialTitle) when the child genuinely forks the data (an edit form with cancel semantics). Name it initial* to signal the contract, and know it will NOT follow later prop changes; if it must reset when the parent swaps records, either watch the identifying prop to reset the draft, or better, :key the child by record id so it remounts with fresh state. (4) Derived transformations belong in computed, not copies: const trimmed = computed(() => props.q.trim()). The follow-up worth pre-empting: deep-cloning props (structuredClone) to make them safely mutable is occasionally right for edit forms but signals an architecture smell when used everywhere.

<script setup lang="ts">
import { ref, computed, watch } from 'vue';

const props = defineProps<{ initialTitle: string; recordId: number }>();
const emit = defineEmits<{ save: [title: string] }>();

// Pattern 3: fork with explicit 'initial' contract (cancel-able draft)
const draft = ref(props.initialTitle);
watch(
  () => props.recordId,           // reset when the parent swaps records
  () => (draft.value = props.initialTitle),
);

// Pattern 2: writable computed bridge (pre-defineModel classic)
const title = computed({
  get: () => draft.value,
  set: (v) => (draft.value = v),
});

function save() {
  emit('save', draft.value);      // parent stays the owner of truth
}

// BUG (silent parent mutation, no warning):
// props.job.title = draft.value;
</script>
Q41

How do you test Vue components with Vitest and Vue Test Utils, and why do async updates trip people up?

IntermediateTesting

Answer

The standard 2026 stack is Vitest (which shares Vite's config and transforms, so SFCs, TS, and aliases work in tests with zero extra setup) plus Vue Test Utils for mounting, running against jsdom or happy-dom. mount renders the component with real children; shallow: true (or shallowMount) stubs children out, and the modern preference is mostly real mounting with targeted stubs (global.stubs for a heavy chart or a RouterLink) because over-stubbing turns tests into implementation mirrors. The philosophy interviewers listen for: assert on rendered output and emitted events (the component's contract), not on internal state via wrapper.vm, which couples tests to refactorable details; many teams push further into @testing-library/vue, which only exposes user-facing queries. Async is where candidates fail live tests.

Vue batches DOM updates into microtasks, so after wrapper.setProps, store mutations, or a direct state change, the DOM is stale until you await something: trigger('click') conveniently returns nextTick's promise, so await wrapper.find('button').trigger('click') covers most cases; a bare state mutation needs await nextTick(); and anything involving a resolved API mock needs flushPromises() from VTU (or vi.runAllTimersAsync with fake timers) to drain the microtask queue before asserting. Emitted events are asserted via wrapper.emitted('save'), which returns an array of call-argument arrays. Dependencies get injected through global.plugins (router, i18n, createTestingPinia), global.provide for inject-based context, and vi.mock for module-level API clients. The one-line summary that plays well: mount real things, drive them like a user, await every interaction, and assert only on what a user or parent component could observe.

import { mount, flushPromises } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import JobCard from '@/components/JobCard.vue';

vi.mock('@/api/client', () => ({
  api: { post: vi.fn().mockResolvedValue({ ok: true }) },
}));

describe('JobCard', () => {
  it('emits apply with the job id after confirmation', async () => {
    const wrapper = mount(JobCard, {
      props: { job: { id: 42, title: 'Vue Engineer', city: 'Pune' } },
      global: { stubs: ['RouterLink'] },
    });

    // trigger returns nextTick's promise: await it or assert stale DOM
    await wrapper.find('[data-test="apply"]').trigger('click');
    await wrapper.find('[data-test="confirm"]').trigger('click');
    await flushPromises(); // drain the mocked API resolution

    expect(wrapper.emitted('apply')).toEqual([[42]]);
    expect(wrapper.text()).toContain('Applied');
  });
});
Q42

How do you unit test Pinia stores and composables, including ones that use lifecycle hooks?

IntermediateTesting

Answer

Stores and composables split into three testing situations. (1) Testing a store directly: call setActivePinia(createPinia()) in beforeEach so each test gets a fresh, isolated container, then use the store as a plain object, mutate, call actions (mocking their API dependencies with vi.mock), and assert on state and getters. Without setActivePinia you get the 'no active Pinia' error or, worse, state bleeding between tests. (2) Testing components that consume stores: @pinia/testing's createTestingPinia goes into global.plugins. It creates stores where actions are stubbed by default (stubActions: true), so the component test asserts 'clicking Apply calls jobsStore.apply with id 42' without executing real network logic, and initialState lets you seed exact store state per test, including getters becoming writable for scenario setup.

Pass stubActions: false when you want real action logic in an integration-style test. (3) Testing composables: a pure composable (no lifecycle, no inject) is just a function, call it, mutate its returned refs, assert. But a composable that registers onMounted/onUnmounted or calls inject needs a component context, so the standard trick is a withSetup helper that mounts a throwaway component whose setup runs the composable, returning both the composable's result and the app/wrapper so you can unmount and assert cleanup happened, the part most candidates forget to test at all: assert that the interval stops or the listener detaches after unmount, because leak regressions are precisely what these tests should catch. Effects scheduled by watchers inside stores or composables still follow Vue's microtask batching, so the same await nextTick()/flushPromises discipline from component testing applies.

// store test: real logic, fresh pinia per test
import { setActivePinia, createPinia } from 'pinia';
import { useJobsStore } from '@/stores/jobs';

beforeEach(() => setActivePinia(createPinia()));

it('filters by city via getter', async () => {
  const store = useJobsStore();
  store.items = [
    { id: 1, city: 'Pune' },
    { id: 2, city: 'Delhi NCR' },
  ];
  store.city = 'Pune';
  expect(store.inCity).toHaveLength(1);
});

// composable-with-lifecycle test: withSetup harness
import { createApp, type App } from 'vue';

function withSetup<T>(composable: () => T): [T, App] {
  let result!: T;
  const app = createApp({
    setup() {
      result = composable();
      return () => null;
    },
  });
  app.mount(document.createElement('div'));
  return [result, app];
}

it('detaches listener on unmount', () => {
  const spy = vi.spyOn(window, 'removeEventListener');
  const [, app] = withSetup(() => useEventListener(window, 'resize', () => {}));
  app.unmount();
  expect(spy).toHaveBeenCalledWith('resize', expect.any(Function));
});
Q43

What does a strict TypeScript setup for Vue look like: vue-tsc, typed emits, generic components, and typed template refs?

IntermediateTypeScript

Answer

Editor intelligence comes from the Vue - Official extension (the Volar language server), and CI enforcement comes from vue-tsc, the TypeScript CLI wrapper that type-checks inside SFC templates, not just script blocks: a typo'd prop in a template, a wrong event payload, a nullable access in an interpolation all fail npx vue-tsc --noEmit. create-vue wires this into the build script, and teams that skip it in CI ship template-level type errors, which is worth saying explicitly since this repo-level discipline is a favourite senior screen. The typing surface you should command: props via the defineProps generic (with 3.5 reactive destructure for defaults); emits via the call-tuple syntax, defineEmits<{ save: [draft: Draft]; page: [n: number] }>, which types both emit calls in the child and @save handlers in the parent; defineModel<T> for two-way contracts; and slots via defineSlots<{ default(props: { item: T }): any }>, which makes scoped slot props check in consumers. Generic components use the generic attribute on the script tag, <script setup lang="ts" generic="T extends { id: number }">, so a DataTable's items prop and its row slot share one T, and misuse fails at the call site, the single feature that makes typed design systems in Vue viable.

Template refs: useTemplateRef<HTMLInputElement>('el') for elements, and for component instances InstanceType<typeof MyComp> exposes only what the child defineExposed. Composables should type inputs as MaybeRefOrGetter<T> and return Ref<T>s. Configuration notes that show real experience: strict: true in tsconfig, moduleResolution: 'bundler' for Vite-era resolution, and typing app-level globalProperties requires a ComponentCustomProperties module augmentation, one more reason to prefer provide/inject with typed InjectionKeys.

<!-- DataTable.vue: generic component + typed slots -->
<script setup lang="ts" generic="T extends { id: number }">
const props = defineProps<{
  items: T[];
  rowClass?: (row: T) => string;
}>();

const emit = defineEmits<{ select: [row: T] }>();

defineSlots<{
  default(p: { row: T; index: number }): any;
  empty(): any;
}>();
</script>

<template>
  <table>
    <tr
      v-for="(row, i) in items"
      :key="row.id"
      :class="rowClass?.(row)"
      @click="emit('select', row)"
    >
      <slot :row="row" :index="i" />
    </tr>
  </table>
  <slot v-if="!items.length" name="empty" />
</template>

<!-- Consumer: `row` is fully typed as Job; wrong usage fails vue-tsc -->
<!-- <DataTable :items="jobs" @select="openJob">
       <template #default="{ row }">{{ row.title }}</template>
     </DataTable> -->
Q44

Module-scope state in a composable: when is it a neat singleton, and when does it become cross-request state pollution?

IntermediateState Management

Answer

A tempting pattern: declare a ref at module scope and export a composable that returns it, giving every component the same instance, a zero-dependency global store. In a pure client-side SPA this genuinely works: the module is evaluated once per browser tab, all consumers share the state, and reactivity flows normally. It even has legitimate uses for small cross-cutting concerns like a toast queue or a theme flag where installing Pinia feels heavy.

The danger begins with server-side rendering. On the server, your module is loaded once per Node process, not once per request, while a new app instance is created per request. Any module-scope ref is therefore shared mutable state across concurrent users: user A's request writes their profile into the singleton, user B's overlapping request renders it, and you have leaked one user's data into another's HTML, the classic cross-request state pollution bug that the Vue SSR docs warn about by name.

It is nasty because it never reproduces locally with one developer clicking around; it appears under concurrent production traffic as intermittent wrong-user data, the kind of bug that becomes a security incident writeup. The fixes, in order of typicality: keep shared state in Pinia, which creates a fresh pinia container per request and serializes it into the payload for hydration; or scope hand-rolled state to the app via app.provide in a plugin so each request's app owns its own copy; or ensure the composable creates fresh state per call and shares it down the tree, not across the module. The interview framing that lands: module scope is per-process, per-request is what you actually want, and only app-instance-scoped mechanisms (Pinia, provide/inject) give you that on the server. If a codebase is SPA-only forever, the singleton is defensible; write it down, because 'we will never SSR' has a short shelf life once SEO requirements arrive.

// DANGEROUS with SSR: one ref per Node PROCESS, shared by all requests
import { ref } from 'vue';
const currentUser = ref<User | null>(null); // module scope!
export function useCurrentUserSingleton() {
  return { currentUser };
}
// Under concurrent SSR traffic, user A's data can render in user B's HTML.

// SAFE: state owned by the per-request app instance
import type { App, InjectionKey, Ref } from 'vue';
import { ref as makeRef, inject } from 'vue';

const UserKey: InjectionKey<Ref<User | null>> = Symbol('user');

export function installUserState(app: App) {
  app.provide(UserKey, makeRef<User | null>(null)); // fresh per createSSRApp
}

export function useCurrentUser() {
  const user = inject(UserKey);
  if (!user) throw new Error('installUserState missing');
  return user;
}
// (Or just use Pinia: fresh pinia per request + payload serialization.)
Q45

How do v-model modifiers work on inputs and custom components, and where do VeeValidate or similar libraries take over?

IntermediateForms

Answer

Native v-model modifiers adjust when and how input flows into state. .lazy syncs on the change event instead of every input event, so state updates when the field blurs or the user presses enter, useful when each update triggers something expensive like validation or a draft save. .number runs the value through parseFloat and keeps the string when parsing fails entirely, a nuance worth stating precisely because interviewers poke at it (typing '12abc' yields 12; typing 'abc' stays 'abc'), and it is applied automatically when the input carries type="number". .trim strips leading and trailing whitespace, the difference between 'why does login fail with a correct password' tickets and none. Custom components can define their own modifiers: with defineModel, passing options and destructuring [model, modifiers] exposes which modifiers the parent wrote (v-model.capitalize="name"), and the get/set transform options on defineModel implement the actual behaviour; with multiple named models each gets its own modifiers object. That machinery is enough for individual widgets, but forms at product scale, multi-step onboarding, dependent fields, server-side error merging, per-field touched/dirty tracking, is where hand-rolled watchers collapse into spaghetti, and the standard answers in the Vue ecosystem are VeeValidate (composition-first: useForm and useField wire values, validation, and error messages, with schema validation delegated to zod or yup via typed schema adapters) or lighter schema-driven setups where you validate a reactive form object against a zod schema on submit and map issues to fields. The architectural point to volunteer: validate at the schema level so the same zod schema types your API payload and drives the form errors, keeping client validation, TypeScript types, and server contract from drifting apart, and always revalidate on the server regardless.

<!-- Native modifiers -->
<template>
  <input v-model.trim="email" placeholder="you@company.com" />
  <input v-model.number="expectedLpa" type="number" />
  <textarea v-model.lazy="coverLetter" /> <!-- syncs on change, not keystroke -->
</template>

<!-- Custom modifier via defineModel (child component) -->
<script setup lang="ts">
const [name, modifiers] = defineModel<string>({
  set(v) {
    if (modifiers.capitalize && v) {
      return v.charAt(0).toUpperCase() + v.slice(1);
    }
    return v;
  },
});
</script>
<template>
  <input :value="name" @input="name = ($event.target as HTMLInputElement).value" />
</template>
<!-- Parent: <NameInput v-model.capitalize="fullName" /> -->
Q46

A Vue SPA's memory grows over hours of use. What are the usual suspects, and how do you hunt them down?

IntermediateProduction Debugging

Answer

Long-session memory growth in Vue apps almost always traces to references that outlive the components that created them. The suspect list, roughly in the order I would check: (1) Listeners and timers attached to long-lived targets, window.addEventListener, document-level handlers from dropdown/tooltip code, setInterval polls, registered in onMounted without the mirror-image cleanup in onUnmounted; each keeps its closure alive, and the closure keeps the component's entire reactive scope alive. (2) KeepAlive without max: every distinct cached component instance holds its full state and detached DOM by design; an unbounded cache over tab-heavy admin usage is a slow, deliberate leak. (3) Third-party widgets (charts, maps, editors) whose instances have their own destroy()/dispose() APIs that nobody calls on unmount; these also pin large canvases and worker threads. (4) External subscriptions, socket.io rooms, RxJS subscriptions, store $subscribe handles created with detached: true, that keep pushing into refs of dead components. (5) Module-scope caches and Maps keyed by object where entries are never evicted (use WeakMap when the key's lifetime should govern). The hunting method matters as much as the list: reproduce with the three-snapshot technique in Chrome DevTools Memory panel, snapshot, perform the suspect flow (open/close the modal ten times), snapshot again, and use the comparison view filtered to 'Detached' to find detached DOM trees, then follow retainer chains upward until you hit your own code, typically a listener or a closure in a composable.

In Performance panel, a sawtooth that ratchets upward across GCs confirms real growth versus normal churn. Vue-specific tells in retainer chains include effect and dep objects pinning component proxies, which usually means a watcher created outside a component context (or after an await) that never auto-stopped, the fix being explicit stop handles or effectScope ownership.

// The three most common leak shapes, and their fixes
import { onMounted, onUnmounted, watch } from 'vue';

// 1) Long-lived target + no cleanup
onMounted(() => {
  const onScroll = () => update();
  window.addEventListener('scroll', onScroll, { passive: true });
  onUnmounted(() => window.removeEventListener('scroll', onScroll));
});

// 2) Interval poll
onMounted(() => {
  const id = setInterval(refreshStatus, 15000);
  onUnmounted(() => clearInterval(id));
});

// 3) Watcher created after await: no owner, never auto-stops
async function init() {
  await loadConfig();
  const stop = watch(source, sync); // outside component context now
  onUnmounted(stop); // WRONG here (no instance); capture stop and
                     // call it from a hook registered BEFORE the await
}

// KeepAlive: always bound the cache
// <KeepAlive :max="5"><component :is="tab" /></KeepAlive>
Q47

Props, emits, provide/inject, Pinia, mitt: how do you choose a communication mechanism between components?

IntermediateArchitecture

Answer

The decision is about relationship and ownership, and interviewers ask it to see whether you have a framework rather than a habit. Parent-child with a clear owner: props down, emits up, defineModel when it is really a two-way editing contract. This should cover the strong majority of communication in a healthy codebase, and its visibility is the point, both sides of the contract appear in the component's interface.

Ancestor to a deep subtree, where intermediate layers should not care: provide/inject, for context-shaped data (theme, form coordination, the current tenant), with Symbol keys, readonly wrapping, and mutation exposed as provided callbacks. Unrelated components, or state that outlives any component (auth session, cart, cached lists): Pinia, which buys devtools time-travel, SSR serialization, and testability; the smell to avoid is promoting every piece of state to a store, local UI state (an open flag, a draft input) belongs in the component. Sibling-to-sibling: lift to the shared parent if they have one nearby, otherwise it is store territory; Vue 3 removed $on/$off, so the Vue 2 event-bus habit has no native home. mitt (a tiny external emitter) survives for genuinely broadcast-shaped, fire-and-forget signals, 'open the global command palette', 'analytics event', where a store would be ceremony; its dangers are invisible coupling and listener leaks, so keep the event catalogue tiny, typed, and centrally defined, and unsubscribe in onUnmounted.

Two mechanisms candidates forget: the URL, filters, pagination, selected tab often belong in route query params so state survives refresh and is shareable, with the router as the communication bus; and scoped slots, which pass data and functions to consumer-authored markup, often eliminating the need for emit gymnastics in composition-heavy UI. Strong answers also name the failure mode of each choice, prop drilling, untraceable inject, god stores, event-bus spaghetti, and pick the least powerful tool that fits.

Key Points

  • Props/emits first: visible contracts cover most communication
  • provide/inject for subtree context; Symbol keys + readonly discipline
  • Pinia for cross-cutting or long-lived state, not local UI flags
  • mitt only for broadcast fire-and-forget; typed events, unsubscribe on unmount
  • Route query params are state too: filters and tabs belong in the URL
  • Pick the least powerful mechanism that solves the relationship
Q48

How do you internationalize a Vue application with vue-i18n, and what are the performance and SSR considerations?

IntermediateEcosystem

Answer

vue-i18n is the standard i18n library for Vue 3. Setup: createI18n({ legacy: false, locale: 'en', fallbackLocale: 'en', messages }) installed via app.use; legacy: false selects Composition API mode, where components call useI18n() to get t, locale, and formatting helpers, instead of the Options-era $t on every instance (still available for migration codebases). Messages are per-locale objects supporting named interpolation (t('greet', { name })), pluralization via pipe-separated forms with t('applied', n) selecting the right branch, and datetime/number formatting driven by the Intl API through d() and n() with per-locale format presets, which is how you get ₹ currency and Indian digit grouping (12,34,567) correctly from the en-IN locale rather than hand-formatting.

For India-market products this is rarely optional: job platforms and fintech apps commonly ship Hindi and other regional languages, and the difference between a demo and production i18n is the operational side: lazy-loading locales so users do not download every language (dynamic import of the locale JSON, then i18n.global.setLocaleMessage(locale, messages) before switching locale), persisting the choice, and setting document direction and the html lang attribute on switch. Performance: the message compiler can run at build time via @intlify/unplugin-vue-i18n, which pre-compiles messages into render-ready functions, shrinking runtime cost and enabling the smaller runtime-only build of vue-i18n; keep message catalogues out of the main chunk. SSR notes: locale must be resolved per request (from the URL prefix, cookie, or Accept-Language) and the i18n instance created per request app to avoid one user's locale leaking into another's HTML, the same cross-request pollution rule as any per-request state; localized routes are usually modeled as a path prefix (/hi/jobs) which doubles as the SEO-correct structure with hreflang alternates. Interviewers may also ask about ICU-style message complexity, translation workflow (keys extracted and managed in a TMS), and testing with a stub i18n in global.plugins.

// i18n.ts
import { createI18n } from 'vue-i18n';

export const i18n = createI18n({
  legacy: false, // Composition API mode: useI18n()
  locale: 'en',
  fallbackLocale: 'en',
  messages: { en: await import('./locales/en.json').then((m) => m.default) },
  numberFormats: {
    'en-IN': { inr: { style: 'currency', currency: 'INR' } },
  },
});

// Lazy-load a locale on demand
export async function switchLocale(locale: string) {
  if (!i18n.global.availableLocales.includes(locale)) {
    const msgs = await import(`./locales/${locale}.json`);
    i18n.global.setLocaleMessage(locale, msgs.default);
  }
  i18n.global.locale.value = locale;
  document.documentElement.lang = locale;
}

// Component
// const { t, n } = useI18n();
// t('jobs.applied', 3)  -> pluralized form
// n(1250000, 'inr', 'en-IN') -> ₹12,50,000.00
Q49

Walk through how Vue's reactivity system tracks and triggers effects internally. What changed in the 3.5 refactor?

AdvancedInternals

Answer

The system has three moving parts: reactive sources (proxied objects and refs), effects (render functions, watchers, computeds, anything created through ReactiveEffect), and the dependency graph connecting them. When an effect runs, it sets itself as the active effect; every property read during that run passes through the proxy's get trap, which calls track(target, key), recording the relationship in a global WeakMap commonly described as targetMap: WeakMap<target, Map<key, Dep>>, where each Dep is the set of effects subscribed to that key. The WeakMap matters: when a reactive object becomes unreachable, its entire dependency bookkeeping is garbage-collected with it.

Writes hit the set trap, which calls trigger(target, key), looks up the Dep, and schedules its subscribers, not synchronously running them but pushing render effects into a queue flushed on the next microtask, deduplicated per component, which is why ten mutations produce one re-render and why nextTick exists. Dependency sets are rebuilt on every effect run, so branches work correctly: an effect that reads state.b only when state.a is true will stop being triggered by b when the branch flips. Computeds add lazy evaluation with dirty tracking: reads return a cached value until an upstream change marks the computed dirty, and since 3.4 a recomputation that produces an identical value does not notify downstream subscribers, cutting cascade re-renders.

The 3.5 reactivity refactor reworked this bookkeeping around version counting and a doubly-linked-list dependency structure (ideas influenced by preact signals): effects and deps link to each other directly, invalidation checks compare version numbers instead of walking sets, and the result was substantially reduced memory usage and faster tracking on large graphs, with no public API change. Being able to narrate track/trigger plus the scheduler is the single most reliable senior-Vue signal in interviews.

Key Points

  • get trap -> track(target, key): records active effect in targetMap deps
  • set trap -> trigger(target, key): schedules subscribers via microtask queue
  • Deps rebuilt per run: conditional reads prune stale subscriptions
  • Computeds: lazy + dirty flags; 3.4+ skips notify when value is unchanged
  • 3.5 refactor: version counting + linked-list deps, big memory reduction
Q50

What do patch flags, the block tree, and static caching do in Vue's compiler, and why can templates beat hand-written render functions?

AdvancedInternals

Answer

Vue 3's renderer is a virtual DOM, but the template compiler feeds it enough static analysis to skip most of the classic VDOM tax. Three mechanisms interlock. Patch flags: for each dynamic element, the compiler emits a bitmask saying exactly what can change, 1 for dynamic text, 2 for dynamic class, 4 for style, 8 for non-class/style props (with the prop names listed), and so on.

During patching, the runtime switches on the flag and diffs only that: an element flagged TEXT gets a text comparison and nothing else, instead of a full props-and-children diff. Block tree: a template is divided into blocks at structural directives (v-if branches, v-for). Within a block, the DOM structure is static, so the compiler collects just the dynamic nodes into a flat dynamicChildren array; updating a block walks that flat array, skipping every static node entirely, making update cost proportional to dynamic content, not template size.

Structural changes swap whole blocks instead of diffing across them (v-for blocks still diff their keyed children). Static caching: fully static subtrees are cached and reused across re-renders rather than re-created (with large static chunks additionally stringified for faster mounts), so re-renders allocate vnodes only for what can vary. This is why idiomatic templates often outperform hand-written render functions: h() calls produce untyped-to-the-compiler, assume-anything vnodes that must be fully diffed, while templates carry compiler guarantees.

It is also why copying JSX-heavy habits from React into Vue can be a de-optimization. Two corollaries worth stating: these optimizations assume the compiler can see the structure, so dynamic component trees and render functions opt out block-by-block; and this compile-time-information trajectory is exactly the road that leads to Vapor mode, where the same analysis compiles away the VDOM entirely.

Q51

What is Vapor Mode, how does it change what a Vue component compiles to, and what trade-offs does it carry?

AdvancedVersions & Internals

Answer

Vapor Mode is Vue's alternative compilation strategy, developed in the 3.6 line as an experimental opt-in: instead of compiling a template into a render function that returns virtual DOM for a runtime diff, the compiler emits code that creates the component's DOM once and wires fine-grained reactive effects directly to the exact DOM operations each binding needs. A {{ title }} interpolation compiles to an effect that sets a captured text node's data when title changes; a :class binding to an effect touching that element's className. No vnode allocation, no diffing, no patch step, updates go straight from the reactivity system to the DOM, the architecture popularized by Solid's compiled fine-grained reactivity, but reached without changing how you author components: the same SFC, <script setup>, and reactivity APIs compile in either mode.

The motivations are proportionally lower memory (no retained vnode trees) and update work for large, mostly-static UIs, plus smaller per-component runtime cost, extending the trajectory the block tree and patch flags started. The trade-offs to state honestly: it is experimental and opt-in (a vapor flag on the SFC), designed for per-component and interop adoption rather than a flag-day switch, and ecosystem compatibility is the long pole, libraries that manipulate vnodes, custom render-function tricks, and some advanced patterns assume the VDOM exists, so mixed applications run both runtimes with an interop boundary while the ecosystem catches up. In an interview, position it as: authoring model stable, execution model swappable, and the practical 2026 advice is to keep templates idiomatic and compiler-analyzable (the same code that optimizes well today is what ports to Vapor cleanly), while watching the feature mature before betting a production migration on it.

Q52

How does Vue SSR hydration work, what causes hydration mismatches, and which Vue 3.5 APIs help?

AdvancedSSR

Answer

Server-side rendering runs your app in Node via createSSRApp plus renderToString from vue/server-renderer, producing HTML that paints immediately. Hydration is the client-side second act: the same app boots in the browser, and instead of creating DOM, it walks the existing server-rendered DOM, adopts each node into its component tree, and attaches listeners and reactivity. A hydration mismatch happens when the client render disagrees with the server HTML, and Vue logs the mismatch warning, patches the DOM to match the client (recovering correctness at the cost of re-rendering work and possible flicker), and you lose much of SSR's benefit.

The recurring causes: (1) invalid HTML nesting, a <div> inside a <p> gets restructured by the browser's parser before hydration, so the DOM Vue finds differs from what the server emitted, a genuinely confusing bug the first time; (2) non-deterministic values, Math.random ids, Date.now timestamps, locale-dependent formatting differing between Node and the browser; (3) environment branching, rendering from window.innerWidth or localStorage-derived state during initial render; (4) state divergence, fetching on the server but re-fetching different data on the client instead of serializing state into the payload; (5) third-party scripts mutating the DOM before hydration finishes. The toolkit: Vue 3.5's useId() generates SSR-stable ids consistent between server and client for exactly the accessibility-attribute and form-label cases where people previously reached for random ids; the data-allow-mismatch attribute (also 3.5) annotates elements where a mismatch is expected and should not warn, with values scoping it to text, class, style, or attribute, right for timestamps rendered in the user's timezone; and the ClientOnly pattern (a wrapper that renders its slot only after mount, ubiquitous in Nuxt) quarantines browser-only widgets. The discipline that prevents the whole class: initial render must be a pure function of serialized state, and anything environment-dependent moves to onMounted.

<script setup>
import { ref, onMounted, useId } from 'vue';

// 3.5: stable across server and client, unlike Math.random ids
const fieldId = useId();

// Browser-only values: never read during initial render
const width = ref(0); // deterministic SSR value
onMounted(() => {
  width.value = window.innerWidth; // safe: after hydration
});
</script>

<template>
  <label :for="fieldId">Email</label>
  <input :id="fieldId" type="email" />

  <!-- Timezone-local timestamp WILL differ from server: opt out of warning -->
  <time data-allow-mismatch="text">{{ new Date().toLocaleString() }}</time>

  <!-- INVALID NESTING: <p> cannot contain <div>; the browser rewrites it
       and hydration walks a different tree than the server emitted -->
  <!-- <p><div>bad</div></p> -->
</template>
Q53

When do you choose Nuxt over a hand-rolled Vue + Vite setup, and what do Nitro, useAsyncData, and the payload actually give you?

AdvancedSSR & Ecosystem

Answer

Nuxt is the meta-framework answer to everything a plain SPA leaves open: rendering strategy, routing conventions, data fetching with serialization, and server deployment. The concrete machinery: file-based routing (pages/ maps to routes, with layouts and middleware conventions), auto-imports for components and composables, and Nitro, the server engine that compiles your server routes (server/api/) and the SSR renderer into a deployable output with presets for Node, serverless, and edge targets, which is why the same Nuxt app deploys to a bare VM or a CDN edge without hand-written server plumbing. Rendering is per-route configurable through route rules: universal SSR for SEO-critical pages, static prerendering for marketing pages, ISR-style caching, or plain SPA mode for the authenticated dashboard, in one app.

The data story is the part interviewers probe: useAsyncData and useFetch run on the server during SSR, serialize results into the payload shipped with the HTML, and on hydration the client reads the payload instead of re-fetching, eliminating the double-fetch and the hydration mismatch that naive SSR data code produces; they also dedupe by key, share cache entries between components, and offer lazy and server: false variants for deferred or client-only loads. When is Nuxt the wrong choice? A purely authenticated tool with no SEO surface (an admin console, an internal ATS dashboard) gets marginal benefit and inherits server operational burden; plain Vue + Vite + vue-router stays simpler to reason about and deploy as static files.

The honest decision rule: public, content-heavy, SEO-dependent surfaces (job listings, blogs, marketplaces, exactly the pages an Indian jobs platform lives on) justify Nuxt; pure app-behind-login usually does not, though teams sometimes adopt Nuxt anyway for its conventions and server routes as a BFF layer. For interviews, be ready to explain payload extraction and per-request state isolation, because 'why is useState in Nuxt per-request but a module ref is not' ties this question back to cross-request pollution fundamentals.

Q54

What is lazy hydration in Vue 3.5, and how do hydration strategies like hydrateOnVisible change SSR performance?

AdvancedPerformance

Answer

Classic SSR hydrates the whole page eagerly: HTML paints fast, but the browser must then download, parse, and execute the full component tree's JavaScript and walk the entire DOM attaching behaviour before anything below the fold is interactive, front-loading main-thread work that hurts responsiveness metrics like INP on mid-range phones, which is most Indian mobile traffic. Vue 3.5 added first-class lazy hydration to defineAsyncComponent via a hydrate option: the server renders the component's HTML normally, but the client defers both loading and hydrating it until a declared trigger fires. The built-in strategies: hydrateOnVisible(observerOptions) hydrates when the element enters the viewport via IntersectionObserver, the workhorse for below-the-fold sections; hydrateOnIdle(timeout) waits for requestIdleCallback, right for important-but-not-critical widgets; hydrateOnInteraction(events) hydrates on the first user interaction like click or mouseover, ideal for expensive interactive islands (a comment editor, a filter panel), with the triggering event replayed after hydration completes so the user's first click is not swallowed; hydrateOnMediaQuery(query) conditions on viewport, letting a mobile layout skip hydrating desktop-only components entirely; and a custom-strategy signature receiving a hydrate callback plus a forEachElement helper for bespoke triggers.

The composition with code splitting is the point: an async component with hydrateOnVisible neither downloads its chunk nor executes it until the user approaches it, converting SSR pages into progressively hydrated islands without adopting a different framework architecture. Limits to acknowledge: the pattern applies to async components you delineate (granularity is your design decision), non-hydrated regions are visible but inert (so avoid it for anything the user needs immediately, and consider CSS affordances for not-yet-interactive states), and measure with the Performance panel, because over-deferring can shift work into the exact moment the user starts interacting. Nuxt exposes the same strategies through its lazy hydration component conventions.

import {
  defineAsyncComponent,
  hydrateOnVisible,
  hydrateOnIdle,
  hydrateOnInteraction,
} from 'vue';

// Below-the-fold: chunk downloads + hydrates when scrolled near
const SalaryInsights = defineAsyncComponent({
  loader: () => import('@/sections/SalaryInsights.vue'),
  hydrate: hydrateOnVisible({ rootMargin: '200px' }),
});

// Nice-to-have widget: waits for main-thread idle
const SimilarJobs = defineAsyncComponent({
  loader: () => import('@/sections/SimilarJobs.vue'),
  hydrate: hydrateOnIdle(2000),
});

// Heavy editor: server HTML is inert until first interaction,
// and the triggering click is replayed post-hydration
const ReviewEditor = defineAsyncComponent({
  loader: () => import('@/sections/ReviewEditor.vue'),
  hydrate: hydrateOnInteraction(['click', 'focusin']),
});
Q55

What is effectScope, and how do libraries like Pinia use it to manage reactivity outside components?

AdvancedInternals

Answer

Every watcher, computed, and watchEffect is a reactive effect that must eventually be stopped, or it keeps firing and retaining memory forever. Inside a component, Vue silently wraps setup in a scope, effects created there are collected and stopped together on unmount, which is why component code never thinks about this. effectScope() exposes that same machinery as an API: const scope = effectScope() creates a collector, scope.run(fn) executes fn with the scope active so every effect created inside is owned by it, and scope.stop() disposes all of them at once, including nested child scopes. onScopeDispose(fn) is the scope-generic sibling of onUnmounted: a composable that registers cleanup through it works identically inside a component or inside a manual scope, which is exactly how VueUse composables remain usable in non-component contexts. The consumers of this API are infrastructure code: Pinia runs each store's state, getters (computeds), and internal watchers inside a scope attached to the pinia instance, which is what makes store.$dispose() and clean SSR teardown possible; router and devtools integrations do the same.

Application-level uses: a long-lived service module (say a realtime price feed shared by many components) creates a detached scope, effectScope(true), so it does not die with whichever component happened to create it first, paired with reference counting to stop it when the last consumer unmounts, this is precisely the createSharedComposable pattern. The interview red flag it addresses: watchers created at module level or in plain functions run under no scope, never auto-stop, and become the leak from the memory-debugging question; wrapping such code in an explicit scope converts an invisible leak into a managed lifetime. getCurrentScope() lets a composable detect whether it has an owner and warn otherwise, a defensive pattern worth mentioning.

import { effectScope, onScopeDispose, ref, watch, computed } from 'vue';

// Shared service: ONE reactive graph, reference-counted across consumers
let scope: ReturnType<typeof effectScope> | null = null;
let subscribers = 0;
let state: { price: ReturnType<typeof ref<number>> } | null = null;

export function usePriceFeed() {
  subscribers++;
  if (!scope) {
    scope = effectScope(true); // detached: outlives any one component
    state = scope.run(() => {
      const price = ref(0);
      const ws = new WebSocket('wss://feed.example.com');
      ws.onmessage = (e) => (price.value = JSON.parse(e.data).ltp);
      watch(price, (p) => console.debug('tick', p));
      onScopeDispose(() => ws.close()); // runs on scope.stop()
      return { price };
    })!;
  }
  onScopeDispose(() => {
    if (--subscribers === 0) {
      scope!.stop(); // every watcher + the socket die together
      scope = null;
      state = null;
    }
  });
  return state!;
}
Q56

Implement a debounced ref with customRef and explain when customRef is the right abstraction.

AdvancedReactivity

Answer

customRef hands you the two levers the reactivity system runs on: track (record that the current effect depends on this ref) and trigger (notify dependents that it changed). You pass a factory receiving both and return an object with get and set; Vue does nothing automatically, so you decide when reads are tracked and, crucially, when writes announce themselves. The canonical use is the debounced ref: writes update the internal value immediately, but trigger is deferred behind a timer, so v-model on a search input stays instant for the user while every watcher, computed, and component depending on the ref sees the value only after typing pauses.

This beats the naive alternatives: debouncing the API call inside a watcher scatters the policy across consumers, while a debounced ref centralizes it at the source, every dependent (the fetch watcher, a results counter, an analytics effect) inherits the debounce for free, and the component template still reflects keystrokes instantly because get returns the current internal value. Other legitimate customRefs: a throttled ref (trailing-edge trigger with a max interval), a localStorage-backed ref that writes through on set and tracks on get, a validating ref that rejects writes failing a predicate (triggering only on accepted values), and bridge refs syncing to external systems like URL query params. When is it the wrong tool?

When you actually need old-and-new comparison, cancellation, or async sequencing, that is watcher territory; and shared app state with policies belongs in a store where devtools can see it. Two implementation details interviewers check: clear the pending timer on every set or rapid typing fires stale triggers, and call track() inside get unconditionally, otherwise effects that read before any write never subscribe. Recent VueUse ships refDebounced doing exactly this, so also say when you would not hand-roll it.

import { customRef } from 'vue';

export function useDebouncedRef<T>(value: T, delay = 300) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  return customRef<T>((track, trigger) => ({
    get() {
      track();          // subscribe the reading effect
      return value;     // reads are always current (input feels instant)
    },
    set(next) {
      value = next;     // store immediately
      clearTimeout(timer); // cancel the stale announcement
      timer = setTimeout(() => {
        trigger();      // notify watchers/computeds AFTER the pause
      }, delay);
    },
  }));
}

// const q = useDebouncedRef('', 400);
// <input v-model="q" />           // updates instantly for the user
// watch(q, fetchResults);          // fires once typing pauses 400ms
Q57

A Vue page is janky: slow interactions and long renders. Describe your actual profiling workflow and the fixes you expect to apply.

AdvancedPerformance

Answer

Workflow first, fixes second, interviewers are testing whether you measure before touching code. Step one: reproduce with CPU throttling (4x-6x in Chrome DevTools) because your machine hides what a mid-range phone on Indian mobile networks experiences. Step two: record the interaction in the Performance panel and read the flame chart; with app.config.performance = true in development, Vue emits User Timing marks per component for init, render, and patch, so long tasks decompose into named components instead of anonymous framework frames.

Step three: the Vue DevTools profiler attributes render counts and durations per component, which answers the central diagnostic question: is this one expensive component, or a re-render storm touching hundreds of cheap ones? The usual verdicts and their fixes: (1) Re-render storms from over-broad reactivity, a giant reactive object where mutating one field invalidates consumers of everything; fix by narrowing dependencies (component reads a computed slice, not the raw store), splitting components so invalidation boundaries shrink, and remembering child components only re-render when their own reactive dependencies change, so extracting a subtree into a child with stable props is itself an optimization. (2) Big-list costs: virtual scrolling (vue-virtual-scroller or a hand-rolled window over a computed slice) so DOM scales with viewport, plus shallowRef for the data and v-memo for selection-style per-row state. (3) Deep-proxy tax on large payloads: shallowRef/markRaw as covered by the data-layer contract. (4) Expensive synchronous work in watchers or computed chains, move it off the interaction path (debounce, requestIdleCallback, a Web Worker for genuine computation like client-side search scoring). (5) Hydration and bundle weight on first load: route-level splitting, lazy hydration, and rollup-plugin-visualizer to find the accidental 300 KB dependency. Close the loop by re-recording and by watching field metrics (INP, LCP) in your RUM tool, lab wins that do not move field numbers are not wins.

Key Points

  • Throttle CPU, record Performance panel, enable app.config.performance marks
  • Vue DevTools profiler: one slow component vs a re-render storm
  • Narrow reactive dependencies; component boundaries are invalidation boundaries
  • Virtual scrolling + shallowRef + v-memo for large lists
  • Move heavy sync work off the interaction path (debounce, workers)
  • Verify against field INP/LCP, not just lab traces
Q58

When do you drop templates for render functions or JSX in Vue, and what do you give up?

AdvancedRendering

Answer

h(type, props, children) creates vnodes directly, and a component can return a render function from setup, gaining the full power of JavaScript for structures templates express awkwardly: rendering a recursive tree of unknown depth, choosing among dozens of dynamic component types, building a heading component that picks h1-h6 from a level prop, or library code that programmatically wraps and transforms children. In <script setup> era code, the cleanest form is a setup that returns () => h(...), keeping closure access to reactive state. JSX via @vitejs/plugin-vue-jsx offers the same semantics with XML ergonomics, and is genuinely common in component-library codebases (many headless UI libraries and admin-panel ecosystems in the Vue world are JSX-heavy) but rare in application code.

What you give up is concrete, not stylistic: the template compiler's static analysis, patch flags, block tree, static caching, none of it applies to hand-built vnodes, so a render-function component pays full VDOM diffing costs on every update; Vue's JSX transform does not get React-community tooling assumptions either, since slots, v-model equivalents, and event casing differ (onClick, withModifiers, passing slots as functions in the children object). You also lose scoped-style co-location subtleties and some template-level lint coverage. The practical decision rule that lands in interviews: templates for everything by default (better optimized, better tooling, designers and reviewers read them), render functions for the structural-metaprogramming cases where templates degenerate into v-if pyramids or string-keyed component maps, and functional components (a plain function receiving props and context) for tiny presentational wrappers with zero state. Two details that mark real experience: slots in render functions are functions you call (slots.default?.()), children passed to components must be wrapped in slot-function objects to avoid a performance warning; and useSlots/useAttrs bridge the gap when a mostly-template component needs programmatic child inspection, which is often the better compromise than a full rewrite to h().

// Heading.tsx style: level-driven tag, awkward in a template
import { h, defineComponent } from 'vue';

export const Heading = defineComponent({
  props: { level: { type: Number, required: true } },
  setup(props, { slots }) {
    // closure over reactive props; slots are FUNCTIONS you invoke
    return () => h('h' + Math.min(props.level, 6), { class: 'gs-heading' },
      slots.default?.());
  },
});

// Recursive tree: the structural case templates handle poorly
export const TreeNode = defineComponent({
  props: { node: { type: Object, required: true } },
  setup(props) {
    return () =>
      h('li', [
        h('span', props.node.label),
        props.node.children?.length
          ? h('ul', props.node.children.map((c) =>
              h(TreeNode, { node: c, key: c.id })))
          : null,
      ]);
  },
});
Q59

How do you structure a Vue codebase that stays maintainable at 500+ components and multiple teams?

AdvancedArchitecture

Answer

Scale problems in Vue codebases are ownership and boundary problems, so the structure should encode both. Organize by feature, not by kind: src/features/jobs, src/features/checkout, each owning its components, composables, stores, API layer, and tests, with a shared src/components (or a workspace package) reserved for the genuinely generic design system. The flat components/ folder with 400 files is the failure mode everyone recognizes; feature folders make deletion safe and reviews scopeable.

Layer the design system explicitly: base primitives (BaseButton, BaseInput, strictly presentation, no store imports ever), composed patterns (SearchField), then feature components; enforce the direction with ESLint import-boundary rules (import/no-restricted-paths or dedicated boundary plugins) so a base component importing a Pinia store fails CI rather than review attention. Stores follow features, one per domain, and stores must not import components; cross-feature reads go through the other feature's store or an explicit public API file (features/jobs/index.ts exports what others may use, everything else is internal). Composables carry the reusable logic; the extraction heuristic is second use, not speculative generality.

Contracts over conventions: strict vue-tsc in CI, typed props/emits/slots everywhere, eslint-plugin-vue at recommended-plus (multi-word component names, no-v-html, require-explicit-emits), and Prettier ending format debates. For multi-team scale add workspace structure (pnpm workspaces or a Turborepo/Nx monorepo) so the design system, app shells, and feature packages version and build independently; true micro-frontends via module federation are the last resort, justified by independent deployment needs, not by folder aesthetics, because they buy team autonomy with runtime-duplication and UX-consistency costs. Finally, institutionalize the patterns from earlier questions, sanctioned SafeHtml wrapper, error boundaries per route section, storeToRefs discipline, KeepAlive caps, because at 500 components, code review cannot police what lint rules and wrappers can enforce structurally.

Q60

Beyond v-html, what belongs in a security review of a production Vue application?

AdvancedSecurity

Answer

A useful review walks the injection surfaces in order of how often teams miss them. (1) URL bindings: :href, :src, :formaction bound to user-influenced values accept javascript: and data: schemes, and Vue does not validate them; centralize an allowlisting sanitizer (permit http, https, mailto, tel) and lint for raw user-URL bindings. (2) Runtime template compilation: anything user-controlled reaching a template string, the rare but catastrophic pattern of building templates from CMS or user data, is code execution by design, since templates compile to functions; production apps should use the runtime-only build (the Vite default, which precompiles SFC templates) so the compiler is not even shipped, and note the full build's runtime compiler relies on Function constructor evaluation, which a strict CSP without unsafe-eval will rightly block. (3) CSP: a real policy (script-src without unsafe-inline, object-src none, frame-ancestors) is the second fence behind every XSS control; Vite's build emits hashable assets that work under nonce-based policies with configuration. (4) Token handling: localStorage tokens are readable by any successful XSS, so prefer httpOnly, SameSite cookies with CSRF protection where the architecture allows, and say the trade-off out loud rather than pretending either option is free. (5) SSR-specific: serializing state into the HTML payload must escape against </script> breakout (use the ecosystem serializers, e.g. devalue-style, not raw JSON.stringify interpolation), and per-request state isolation prevents cross-user leakage covered earlier. (6) Supply chain: lockfiles, npm audit or a scanner in CI, pinned third-party script integrity (SRI), and reviewing what analytics snippets can see, a compromised dependency executes in your origin with all the powers XSS has. (7) Client checks are UX: route guards, disabled buttons, and hidden admin panels must all be re-enforced server-side; hiding an element with v-if while the API endpoint stays open is finding number one in most first-time audits of SPA backends. Framing the answer as escape-by-default plus these named exceptions shows security maturity rather than checklist recital.

Key Points

  • Validate URL schemes on :href/:src; Vue will not do it for you
  • Never compile user-influenced templates; ship the runtime-only build
  • Strict CSP: the runtime compiler needs unsafe-eval, precompiled SFCs do not
  • Escape SSR state serialization against </script> breakout
  • httpOnly cookies vs localStorage tokens: name the XSS trade-off
  • Every client-side gate must have a server-side twin

Companies Hiring Vue.js

GitLab
Grammarly
Upwork
Trivago
TCS
Infosys
Accenture

Salary Insights

Average in India
₹5-18 LPA

Frequently Asked Questions

What salary can a Vue.js developer expect in India in 2026?

The broad band is ₹5-18 LPA. Freshers and developers with under two years typically start at ₹5-8 LPA, mid-level engineers with solid Vue 3, Pinia, and testing experience land ₹10-15 LPA, and seniors who can own SSR, performance, and architecture cross ₹18 LPA, more at product companies. Remote-first global employers like GitLab, which runs one of the largest Vue codebases anywhere and hires from India, pay significantly above local bands. Vue roles are somewhat fewer than React roles in India, but so are strong Vue candidates, so specialists with demonstrable depth (reactivity internals, Nuxt, migration experience) face less competition per opening.

How long does it take to prepare for a Vue.js interview?

If you already work with Vue daily, two focused weeks is realistic: one week on fundamentals you use but cannot explain (reactivity internals, watcher flush timing, slots compilation scope) and one on the topics that decide senior rounds: SSR hydration, performance profiling, testing strategy, and what changed in Vue 3.4 and 3.5. If you are coming from React or Angular, budget four to six weeks: the component model transfers quickly, but interviewers can immediately tell recycled React answers (stale-closure reasoning, dependency arrays) from real Vue understanding of refs, proxies, and template compilation. Either way, build one non-trivial project with <script setup>, Pinia, Vue Router, and Vitest, because most loops now include a live-coding or code-review round in exactly that stack.

What do interviewers expect from freshers vs experienced Vue developers?

Freshers are tested on fundamentals with honesty about depth: ref vs reactive with the destructuring trap, computed vs watch, v-for keys, props/emits flow, and one clean project they can defend line by line. Nobody expects a fresher to explain the 3.5 reactivity refactor. At 2-4 years, expectations jump to composable design, Pinia patterns, router guards, testing with Vitest and Vue Test Utils, and production debugging stories: a hydration mismatch you fixed, a memory leak you found, a bundle you shrank. At senior level, interviews become architecture and trade-off conversations: SSR strategy, performance budgets, migration planning from Vue 2 estates, and the ability to justify choices against alternatives rather than recite features.

Is Vue.js still worth learning in 2026 compared to React?

Yes, with clear eyes about the market shape. React has more openings in India by a wide margin, so Vue should usually be a strong second framework or a deliberate specialization rather than a bet against React. The case for Vue: the 2026 stack (Vite, <script setup>, Pinia, Nuxt, and the compiler-driven performance work culminating in Vapor Mode) is technically excellent and pleasant to work in; Vue dominates certain niches (GitLab's ecosystem, many European and Asian employers, a large share of admin and dashboard tooling); and services companies like TCS, Infosys, and Accenture continuously staff Vue projects, including well-paid Vue 2 to Vue 3 migration work. Framework concepts also transfer: reactivity, component contracts, SSR, and testing discipline are portable assets, not sunk costs.

Should I learn Nuxt along with Vue, and how much Vuex do I still need?

Learn Nuxt once your core Vue is solid, and definitely before applying for roles involving SEO-facing products: universal rendering, useAsyncData, and Nitro server routes are exactly what content-heavy Indian product companies interview on, and Nuxt knowledge is the clearest differentiator between candidates who built dashboards and candidates who shipped public websites. Vuex deserves reading-level familiarity only: understand state/getters/mutations/actions well enough to navigate legacy code and to answer migration questions, because many Indian codebases still run it, but write all new learning projects with Pinia. A candidate who can articulate a concrete Vuex-to-Pinia migration plan turns legacy exposure into an interview advantage rather than a liability.

Which portfolio projects actually impress in Vue.js interviews?

One deep project beats five todo apps. The strongest pattern is a product-shaped app with real constraints: a job board or booking flow with SSR via Nuxt for public pages, authenticated dashboard behind route guards, Pinia stores with tests, forms with schema validation, and at least one hard problem you can narrate, virtualized 10,000-row lists, lazy hydration, an error-boundary strategy, or i18n with an Indic language. Attach the receipts interviewers actually respect: a Lighthouse report, a vue-tsc-clean TypeScript setup, Vitest coverage of stores and composables, and a README explaining trade-offs. Contributing a fix to a Vue ecosystem library (VueUse, a Nuxt module, a component library) is worth more than any additional personal project, because it proves you can read other people's Vue at production scale.

Introduction

Vue.js in 2026 is a very different interview subject than it was five years ago. Vue 3 with the Composition API and <script setup> is the default authoring style, Vite is the standard build tool, Pinia has replaced Vuex as the official state management layer, and the 3.4 and 3.5 releases brought defineModel, useTemplateRef, stable reactive props destructure, and lazy hydration. Interviewers now assume you know all of this. A candidate who still answers in terms of Options API mixins, Vue CLI, and Vuex mutations immediately signals a stale skill set, even if the underlying understanding is solid.

In India, Vue work splits into two markets. Product companies and global remote employers (GitLab runs one of the largest Vue codebases in the world and hires engineers in India; Grammarly, Upwork, and Trivago also ship Vue in production) probe reactivity internals, composable design, SSR hydration, and performance. Services firms like TCS, Infosys, and Accenture staff large Vue projects for international clients, and their interviews lean on migration questions: Vue 2 end-of-life realities, Options-to-Composition refactors, and Vuex-to-Pinia moves. Salaries typically land in the ₹5-18 LPA band, with senior product roles going higher.

This guide contains 60 Vue.js interview questions ordered basic to intermediate to advanced, the same progression a real interview loop follows. Each answer explains how the framework actually behaves in production, the gotchas that trip up experienced candidates, and what the interviewer is really checking. Most technical questions include a runnable code example in the modern style: <script setup>, TypeScript where it matters, Vite-era tooling. Work through the basic section quickly to close gaps, then spend your time on the intermediate and advanced sections, because that is where offers are decided.

Ready to practice Vue.js interviews?

Don't just read, practice these Vue.js questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview