Vue.js Developer Interview Questions
Core Overview
Prepare for Vue.js developer interviews covering Vue 3 Composition API, Single-File Components, reactivity mechanics, Pinia state management, Vue Router, async data flows, TypeScript integration, automated testing, runtime performance, and production architecture.
Ready to test your knowledge?
Launch a focused practice session to review questions without distraction.
What is a Vue Single-File Component (SFC), and how do <template>, <script setup>, and <style scoped> collaborate within the build pipeline and component lifecycle?
Direct Answer
A Vue Single-File Component couples template, script setup, and style blocks into one file. Preprocessors compile the template into a virtual DOM render function, script setup into a component setup function, and scoped CSS into post-processed selectors with unique dataset attributes.
Detailed Explanation
### Architecture of a Single-File Component (SFC)
A Vue Single-File Component (.vue file) encapsulates the three primary facets of UI development—structure, behavior, and styling—in a single file. An SFC is not an HTML file executed directly by browsers; rather, it is a specialized source format transformed by build plugins (such as @vitejs/plugin-vue or vue-loader) into standard ES modules.
`vue
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button type="button" class="counter-btn" @click="increment">
Count is: {{ count }}
</button>
</template>
<style scoped>
.counter-btn {
background-color: #42b883;
color: #ffffff;
padding: 8px 16px;
border-radius: 4px;
}
</style>
---
### How the SFC Blocks Collaborate
1. `<script setup>`:
<template> without requiring an explicit return { ... } block.setup() function.defineProps and defineEmits are compiler-only hints removed during compilation.2. `<template>`:
3. `<style scoped>`:
data-v-7ba5bd90) to every element rendered by the component's template..counter-btn to .counter-btn[data-v-7ba5bd90].:deep() selector is misused.---
### Key Architectural Boundaries
eq$ Browser HTML:** Browsers cannot parse .vue files natively. Tooling decomposes the file into JavaScript render code, script logic, and modular CSS.
<script setup> $eq$ Global Setup:** Logic in <script setup> runs per component instance, preserving instance isolation.
eq$ Shadow DOM:** Styles remain part of the main document stylesheet and do not isolate global inherited rules like fonts or CSS custom properties.
Code Example
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
initialCount?: number
}>()
const emit = defineEmits<{
(e: 'change', value: number): void
}>()
const count = ref(props.initialCount ?? 0)
const isEven = computed(() => count.value % 2 === 0)
function increment() {
count.value++
emit('change', count.value)
}
</script>
<template>
<div class="counter-box">
<p>Status: {{ isEven ? 'Even' : 'Odd' }}</p>
<button @click="increment">Current: {{ count }}</button>
</div>
</template>
<style scoped>
.counter-box {
border: 1px solid #e2e8f0;
padding: 1rem;
}
</style>STAR Method Blueprint
Common Interview Pitfalls
- Assuming browsers can natively import and execute .vue files without a bundler
- Believing scoped styles provide complete Shadow DOM isolation
- Trying to import defineProps or defineEmits from "vue" when they are compiler macros
- Placing non-static module-level side effects inside script setup expecting them to run only once
How do props and emitted events govern parent-child component communication in Vue 3, and why must one-way data flow be preserved?
Direct Answer
Props enforce one-way data flow from parent to child, while emits notify parents of child events. Children must never mutate props directly because parent re-renders overwrite local changes. For two-way synchronization, Vue uses v-model conventions over explicit props and emits.
Detailed Explanation
### One-Way Data Flow (Props Down, Events Up)
In Vue 3, parent-child component communication strictly follows the one-way data flow architecture:
props.emit.[ Parent Component (Owns State) ]
│ ▲
props: │ data │ emit: event notification
(down) ▼ │ (up)
[ Child Component (Consumer / Presenter) ]
---
### Declaring Props and Emits in <script setup>
Vue 3 provides compiler macros defineProps and defineEmits to declare component contracts with full TypeScript type safety:
`vue
<!-- UserProfileCard.vue (Child) -->
<script setup lang="ts">
interface Props {
userId: string
userName: string
isActive?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const emit = defineEmits<{
(e: 'update-status', id: string, active: boolean): void
(e: 'delete', id: string): void
}>()
function toggleStatus() {
// Never: props.isActive = !props.isActive (mutating prop directly)
// Instead: emit intent to parent
emit('update-status', props.userId, !props.isActive)
}
</script>
<template>
<div class="user-card">
<h3>{{ userName }}</h3>
<p>Status: {{ isActive ? 'Active' : 'Inactive' }}</p>
<button @click="toggleStatus">Toggle Status</button>
<button @click="emit('delete', userId)">Delete</button>
</div>
</template>
---
### Why Direct Prop Mutation is Prohibited
1. Overwritten Mutations: Props are read-only reactive bindings reflecting the parent's state. When the parent re-renders for any reason, all prop values in child components are overwritten by the parent's latest values, silently discarding any local mutations.
2. Entangled Data Flow: Mutating objects or arrays passed via props modifies the parent's memory reference without notifying the parent component through an explicit event. This creates unpredictable bugs where state mutations occur without traceable caller intent.
3. Runtime Warning: In development mode, Vue issues a console warning when code attempts to assign directly to a prop property: Set operation on key failed: target is readonly.
---
### Controlled Synchronization via v-model
When two-way synchronization is genuinely required (e.g., form inputs or modal toggles), Vue 3 standardizes this pattern using v-model:
v-model="searchText" on a component is syntactic sugar for::modelValue="searchText"@update:modelValue="val => searchText = val"defineModel() macro (Vue 3.4+) to bind a two-way model cleanly without manual prop and emit boilerplate while preserving explicit parent ownership.Code Example
<!-- ParentComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import UserProfileCard from './UserProfileCard.vue'
const users = ref([
{ id: 'u1', name: 'Alice', active: true },
{ id: 'u2', name: 'Bob', active: false }
])
function handleStatusUpdate(id: string, active: boolean) {
const user = users.value.find(u => u.id === id)
if (user) {
user.active = active
}
}
function handleDeleteUser(id: string) {
users.value = users.value.filter(u => u.id !== id)
}
</script>
<template>
<div>
<UserProfileCard
v-for="user in users"
:key="user.id"
:user-id="user.id"
:user-name="user.name"
:is-active="user.active"
@update-status="handleStatusUpdate"
@delete="handleDeleteUser"
/>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Mutating a prop directly inside a child component, causing silent overwrites on parent re-render
- Mutating properties of an object prop directly, bypassing traceable parent event handlers
- Confusing emit with a direct parent method invocation rather than a dispatched event
- Failing to use withDefaults when providing default values for TypeScript-declared props
How does the Vue 3 Composition API organize state and logic using ref, reactive, and computed, and how do their mental models differ?
Direct Answer
The Composition API organizes code by logical feature rather than component lifecycle options. Ref wraps any value in a reactive container with a dot value property, reactive wraps objects in a proxy without dot value, and computed derives cached read-only state from reactive dependencies.
Detailed Explanation
### The Composition API Mental Model
The Composition API addresses a core limitation of the Vue 2 Options API: in large components, code for a single logical feature was fragmented across data, methods, computed, and multiple lifecycle hooks. The Composition API allows engineers to group related state, business logic, and lifecycle triggers together by concern into composable functions.
---
### ref vs. reactive
| Characteristic | ref(initialValue) | reactive(targetObject) |
| :--- | :--- | :--- |
| Supported Data Types | Any primitive (string, number, boolean) or object | Objects, arrays, Sets, Maps only (rejects primitives) |
| Internal Mechanism | RefImpl object with reactive .value getter/setter | ES6 Proxy wrapping the original object directly |
| Script Access | Requires .value (e.g., count.value++) | Direct property access (e.g., state.count++) |
| Template Access | Automatically unwrapped at top level | Direct property access |
| Destructuring | Retains reactivity (each ref remains a container) | Loses reactivity unless converted using toRefs() |
| Reassignment | Reassigning .value works seamlessly | Reassigning the variable replaces proxy, breaking reactivity |
`ts
import { ref, reactive, toRefs } from 'vue'
// ref: recommended for primitives and reassignable structures
const count = ref(0)
count.value++
// reactive: useful for tightly-coupled state objects
const state = reactive({
username: 'alex',
role: 'admin'
})
// Destructuring loses reactivity without toRefs:
// BAD: const { username } = state // ordinary string, not reactive
// GOOD:
const { username, role } = toRefs(state)
---
### computed: Pure Derived State
computed() accepts a getter function and returns a cached, read-only ComputedRef.
1. Dependency Tracking: When evaluated, computed accesses reactive values, automatically registering them as dependencies.
2. Lazy Cached Evaluation: The getter only re-runs when one of its tracked reactive dependencies changes. Repeated accesses while dependencies remain unchanged return the cached result instantaneously without recomputation.
3. No Side Effects: A computed getter must remain a pure function. It must never initiate network requests, mutate other reactive variables, or manipulate the DOM.
`ts
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, name: 'Desk', price: 200, tax: 20 },
{ id: 2, name: 'Chair', price: 100, tax: 10 }
])
// Pure derivation with caching
const totalPrice = computed(() => {
return items.value.reduce((sum, item) => sum + item.price + item.tax, 0)
})
---
### Core Architectural Guardrails
ref $eq$ Primitive-Only:** ref works with complex objects; internally, if an object is passed to ref, it delegates to reactive for deep proxying.
reactive $eq$ Universally Superior:** reactive cannot track primitive values and causes reactivity loss if destructured or reassigned.
computed $eq$ Side Effect Channel:** Side effects (HTTP calls, storage, timers) belong in event handlers or watch / watchEffect, never in computed.
Code Example
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
// 1. Primitive and object refs
const searchQuery = ref('')
const searchCount = ref(0)
// 2. Grouped reactive state
const pagination = reactive({
page: 1,
pageSize: 10
})
const items = ref(['Vue 3', 'TypeScript', 'Pinia', 'Vite'])
// 3. Cached computed derivation
const filteredItems = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
if (!query) return items.value
return items.value.filter(item => item.toLowerCase().includes(query))
})
// 4. Methods update reactive state
function resetSearch() {
searchQuery.value = ''
pagination.page = 1
}
onMounted(() => {
searchCount.value = items.value.length
})
</script>
<template>
<div>
<input v-model="searchQuery" placeholder="Search..." />
<button @click="resetSearch">Reset</button>
<p>Matches: {{ filteredItems.length }} of {{ searchCount }}</p>
<ul>
<li v-for="item in filteredItems" :key="item">{{ item }}</li>
</ul>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Destructuring properties from a reactive object without toRefs, breaking reactive tracking
- Reassigning an entire reactive object variable directly (e.g. state = reactive({...}))
- Forgetting .value when accessing or modifying refs in JavaScript logic
- Performing asynchronous calls or external state mutations inside computed getters
How do Vue core directives such as v-if, v-show, v-for, and v-on operate during reconciliation, and what pitfalls compromise rendering correctness?
Direct Answer
Directives control template reactivity: v-if adds or removes DOM subtrees while v-show toggles CSS display. V-for iterates over collections and requires a stable, unique key for DOM reconciliation. Using array index as a key or combining v-if with v-for on the same tag causes rendering bugs.
Detailed Explanation
### Core Directive Mechanics
Vue templates compile directives into specialized Virtual DOM branching, looping, and event handler instructions during reconciliation.
---
### 1. v-if vs. v-show
false, child components undergo full unmounting: component state is destroyed, DOM nodes are removed, and lifecycle cleanup (onUnmounted) triggers.false on initial render, Vue skips rendering the subtree entirely.display: none.---
### 2. v-for and Reconciliation Keys
The :key attribute provides the Virtual DOM reconciliation algorithm with a stable identifier to match new VNodes with existing DOM nodes.
`vue
<!-- CORRECT: Stable business domain identifier -->
<li v-for="user in users" :key="user.id">
<input v-model="user.notes" />
{{ user.name }}
</li>
<!-- ANTI-PATTERN: Array index as key -->
<li v-for="(user, index) in users" :key="index">
<input v-model="user.notes" />
{{ user.name }}
</li>
#### Why Array Indices as Keys Fail
When using an array index as :key, inserting, deleting, or sorting items shifts the indices of subsequent items:
1. If item 0 is deleted from a list of 3 items, item 1 now becomes index 0.
2. The Virtual DOM reconciler compares old index 0 with new index 0 and assumes the node identity is unchanged.
3. It updates text content but preserves uncontrolled DOM state (such as focused inputs, form values, and CSS transitions) on the wrong element, causing critical state corruption.
---
### 3. Combining v-if and v-for on the Same Element
In Vue 3, `v-if` has a higher precedence than `v-for`.
When both directives are placed on the exact same HTML element:
`vue
<!-- ERROR: v-if executes first and has no access to the loop variable 'item' -->
<li v-for="item in items" v-if="item.isActive" :key="item.id">
{{ item.name }}
</li>
The template compiler throws a runtime error because item is not defined in the scope evaluated by v-if.
Correct Architectural Solutions:
1. Computed Filter (Preferred): Filter the array in a computed() property before passing it to v-for.
2. `<template>` Wrapper: Wrap the loop in a <template v-for="..."> tag and place v-if on an inner element.
`vue
<!-- Solution A: Computed filtering (Best performance) -->
<li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
<!-- Solution B: Template wrapper -->
<template v-for="item in items" :key="item.id">
<li v-if="item.isActive">{{ item.name }}</li>
</template>
---
### 4. Directives and Event Handling (v-on / @)
v-on binds event listeners to DOM elements or component events. Vue provides event modifiers (.prevent, .stop, .passive, .once) that avoid imperative DOM manipulation inside business logic methods.
Code Example
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Task {
id: string
title: string
isComplete: boolean
}
const tasks = ref<Task[]>([
{ id: 't-1', title: 'Write unit tests', isComplete: true },
{ id: 't-2', title: 'Review PR', isComplete: false },
{ id: 't-3', title: 'Deploy to staging', isComplete: false }
])
const showOnlyPending = ref(false)
// Computed filtering prevents v-if/v-for collisions and avoids re-filtering on every tick
const visibleTasks = computed(() => {
if (!showOnlyPending.value) return tasks.value
return tasks.value.filter(t => !t.isComplete)
})
function toggleTask(id: string) {
const task = tasks.value.find(t => t.id === id)
if (task) task.isComplete = !task.isComplete
}
</script>
<template>
<div class="task-manager">
<label>
<input type="checkbox" v-model="showOnlyPending" />
Show only pending tasks
</label>
<ul>
<li v-for="task in visibleTasks" :key="task.id">
<span :class="{ completed: task.isComplete }">{{ task.title }}</span>
<button @click.stop="toggleTask(task.id)">Toggle</button>
</li>
</ul>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Using array index as :key when items can be filtered, reordered, or deleted
- Placing v-if and v-for on the same DOM element expecting v-for to run first
- Using v-if instead of v-show for elements that toggle frequently with heavy subtrees
- Embedding complex nested JavaScript expressions inside template interpolations
When should a Vue developer architect components with slots, scoped slots, or provide/inject rather than expanding prop APIs, and what trade-offs govern their use?
Direct Answer
Slots delegate template markup and layout rendering to callers, with scoped slots passing child data upward. Provide and inject pass dependencies across deeply nested component trees without prop drilling, though overusing them can obscure data flow and component dependency graphs.
Detailed Explanation
### Component API Design: Beyond Props and Events
As components grow in complexity, attempting to configure every visual variant and behavioral permutation via props leads to "prop bloat"—a design smell where components accept dozens of boolean flags, layout strings, and formatter callbacks. Vue provides slots, scoped slots, and provide/inject as architectural primitives for flexible inversion of control.
---
### Comparison of Extension Primitives
| Pattern | Primary Use Case | Direction of Data Flow | Coupling & Visibility |
| :--- | :--- | :--- | :--- |
| Props & Emits | Direct parent-to-child data and child-to-parent events | Explicit parent $ ightarrow$ child $ ightarrow$ parent | High visibility, strictly typed, shallow |
| Default & Named Slots | Caller controls markup, layout, and visual presentation | Parent template $ ightarrow$ child slot outlets | Decoupled presentation, parent scope |
| Scoped Slots | Caller controls markup while consuming child internal data | Child passes data up to caller's slot template | Inversion of control with consumer data access |
| `provide` / `inject` | Deeply nested dependencies or plugin context | Ancestor $ ightarrow$ arbitrary descendant depth | Bypasses prop drilling, but creates implicit coupling |
---
### 1. Slots and Scoped Slots (Inversion of Control)
Slots delegate template layout to the consumer. Scoped slots allow the child component to handle business logic, data fetching, or keyboard navigation while letting the parent determine exactly how each item is styled and rendered.
`vue
<!-- DataList.vue (Child Component with Scoped Slot) -->
<script setup lang="ts" generic="T">
defineProps<{
items: T[]
loading?: boolean
}>()
</script>
<template>
<div class="data-list">
<div v-if="loading" class="spinner">Loading...</div>
<ul v-else>
<li v-for="(item, index) in items" :key="index">
<!-- Scoped slot exposing item and index to parent -->
<slot name="item" :item="item" :index="index">
<!-- Fallback content if parent provides no slot -->
<span>{{ item }}</span>
</slot>
</li>
</ul>
</div>
</template>
Consumer usage:
`vue
<DataList :items="users" :loading="isLoading">
<template #item="{ item, index }">
<div class="user-row">
<strong>#{{ index + 1 }} {{ item.name }}</strong>
<span>{{ item.email }}</span>
</div>
</template>
</DataList>
---
### 2. provide / inject (Dependency Injection Across Component Trees)
When components form a tightly-coupled composite family (e.g., <Accordion> and <AccordionItem>, or <Tabs> and <TabPane>), passing state through intermediate components creates brittle prop drilling.
provide and inject pass reactive state and control methods directly from an ancestor to any descendant, regardless of nesting depth.
`ts
// keys.ts - Use InjectionKey for TypeScript type safety
import type { InjectionKey, Ref } from 'vue'
export interface AccordionContext {
activeId: Ref<string | null>
selectItem: (id: string) => void
}
export const AccordionKey: InjectionKey<AccordionContext> = Symbol('AccordionKey')
`vue
<!-- Accordion.vue (Provider) -->
<script setup lang="ts">
import { ref, provide, readonly } from 'vue'
import { AccordionKey } from './keys'
const activeId = ref<string | null>(null)
function selectItem(id: string) {
activeId.value = activeId.value === id ? null : id
}
// Provide readonly state + mutator function to prevent uncontrolled descendant mutation
provide(AccordionKey, {
activeId: readonly(activeId),
selectItem
})
</script>
<template>
<div class="accordion"><slot /></div>
</template>
`vue
<!-- AccordionItem.vue (Consumer) -->
<script setup lang="ts">
import { inject } from 'vue'
import { AccordionKey } from './keys'
const props = defineProps<{ id: string }>()
const accordion = inject(AccordionKey)
if (!accordion) {
throw new Error('AccordionItem must be used inside an Accordion component')
}
</script>
<template>
<div class="item">
<header @click="accordion.selectItem(props.id)">
<slot name="header" />
</header>
<div v-show="accordion.activeId.value === props.id">
<slot />
</div>
</div>
</template>
---
### Trade-offs and Best Practices
eq$ Child Components:** A slot is an insertion outlet for VNodes, not a child component instance.
eq$ Global State:** Provide/inject is bounded to the ancestor-descendant component tree. It is not a replacement for global stores like Pinia because it lacks devtools inspection, action logging, and cross-route persistence.
readonly() and provide dedicated updater functions alongside it to keep mutation ownership explicit.Code Example
// context.ts
import { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
theme: Ref<'light' | 'dark'>
toggleTheme: () => void
}
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('ThemeKey')
<!-- ThemeProvider.vue -->
<script setup lang="ts">
import { ref, provide, readonly } from 'vue'
import { ThemeKey } from './context'
const theme = ref<'light' | 'dark'>('light')
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide(ThemeKey, {
theme: readonly(theme),
toggleTheme
})
</script>
<template>
<div :class="['theme-wrapper', theme]">
<slot :current-theme="theme" />
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Creating hundreds of boolean display props instead of letting callers supply slots
- Mutating injected reactive state directly in descendant components without updater functions
- Using string keys for provide/inject instead of typed Symbol InjectionKey instances
- Overusing provide/inject across unrelated features where Pinia or explicit props are clearer
A reusable <UserTable> component copies a users prop into localUsers = ref(props.users) and fetches team data on selectedTeamId changes. In production, users report that searching or filtering in the parent leaves stale table data, rapid team switching displays out-of-order team data, and adding deep watchers causes CPU spikes. How would you diagnose these issues and architect a predictable component?
Direct Answer
The incident stems from capturing props in a local ref that creates competing state, combined with unhandled async race conditions and heavy deep watchers. The fix requires clarifying data ownership, deriving filtered state via computed, and cancelling or tokenizing in-flight network requests.
Detailed Explanation
### Root Cause Analysis
The production breakdown is caused by five distinct architectural failures interacting simultaneously:
1. The Prop Snapshot Anti-Pattern (`ref(props.users)`):
const localUsers = ref(props.users) executes once during component initialization. It snapshots the initial array reference.:users="filteredUsers", Vue updates props.users, but localUsers.value retains the initial reference.2. Async Race Conditions Across Team Switching:
watch(() => props.selectedTeamId, async (teamId) => { localUsers.value = await fetchUsers(teamId) }), network requests are fired sequentially without coordination.$$\text{Team A (slow: 900ms)} \rightarrow \text{Team B (aborted)} \rightarrow \text{Team C (fast: 150ms)}$$
3. Ambiguous Data Ownership:
users prop *and* independently fetches users via selectedTeamId.users to remain the single source of truth, internal team fetching corrupts the page's contract.4. Performance Degradation from Deep Watchers (`deep: true`):
5. Proposals for Prop Copying & Watchers:
---
### Step-by-Step Production Remediation
#### Step 1: Establish Clear Component Ownership
A component should either be purely presentational (parent owns all fetching and filtering) or data-owning (component owns fetching based on an ID), but never both simultaneously without an explicit contract.
users, loading, and error as props and emits user selection events.teamId and encapsulates network fetching using a reusable composable with race cancellation.---
### Architectural Implementation: Safe Presentational Table
`vue
<!-- UserTable.vue (Clean Presentational Architecture) -->
<script setup lang="ts">
import { computed } from 'vue'
export interface User {
id: string
name: string
email: string
active: boolean
}
const props = defineProps<{
users: User[]
loading?: boolean
selectedUserId?: string | null
}>()
const emit = defineEmits<{
(e: 'select-user', user: User): void
}>()
// Pure derived state: computed automatically caches and updates when props.users changes
const activeUsers = computed(() => {
return props.users.filter(user => user.active)
})
</script>
<template>
<div class="user-table-container">
<div v-if="loading" class="table-overlay">Loading team members...</div>
<table v-else class="user-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<!-- Stable key prevents DOM identity corruption -->
<tr
v-for="user in activeUsers"
:key="user.id"
:class="{ selected: user.id === selectedUserId }"
>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td><span class="badge">Active</span></td>
<td>
<button @click="emit('select-user', user)">Select</button>
</td>
</tr>
<tr v-if="activeUsers.length === 0">
<td colspan="4">No active team members found.</td>
</tr>
</tbody>
</table>
</div>
</template>
---
### Handling Async Race Conditions via Composable
If team data fetching is handled on team ID change, use AbortController or generation tokens inside a dedicated composable:
`ts
// useTeamUsers.ts
import { ref, watch, type Ref } from 'vue'
import type { User } from './UserTable.vue'
export function useTeamUsers(teamIdRef: Ref<string>) {
const users = ref<User[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
let abortController: AbortController | null = null
let requestSeq = 0
watch(
teamIdRef,
async (teamId, _, onCleanup) => {
// 1. Abort previous in-flight request
if (abortController) {
abortController.abort()
}
abortController = new AbortController()
const currentSeq = ++requestSeq
// 2. Register cleanup callback executed when dependency changes or unmounts
onCleanup(() => {
abortController?.abort()
})
if (!teamId) {
users.value = []
return
}
loading.value = true
error.value = null
try {
const response = await fetch(/api/teams/${teamId}/users, {
signal: abortController.signal
})
if (!response.ok) throw new Error('Failed to fetch team users')
const data = await response.json()
// 3. Generation token check: verify this response matches latest user intent
if (currentSeq === requestSeq) {
users.value = data
}
} catch (err: any) {
if (err.name === 'AbortError') return // Normal cancellation
if (currentSeq === requestSeq) {
error.value = err
}
} finally {
if (currentSeq === requestSeq) {
loading.value = false
}
}
},
{ immediate: true }
)
return { users, loading, error }
}
---
### Verification and Regression Testing
1. Async Race Test: Mock the network layer to resolve Team A in 500ms and Team B in 50ms. Change teamId from A to B. Verify the component renders Team B data and ignores the subsequent resolution of Team A.
2. Prop Reactivity Test: Mount <UserTable :users="users" />. Update the users prop array. Verify activeUsers and the rendered DOM table update immediately without local ref divergence.
3. Watcher Performance Check: Remove deep watchers on complex collections; watch scalar identifiers (teamId) rather than nested object graphs.
Code Example
// Parent usage combining composable and presentational table
<script setup lang="ts">
import { ref, computed } from 'vue'
import UserTable, { type User } from './UserTable.vue'
import { useTeamUsers } from './useTeamUsers'
const selectedTeamId = ref('team-1')
const searchQuery = ref('')
const selectedUserId = ref<string | null>(null)
// Dedicated async composable handles race conditions and loading state
const { users, loading, error } = useTeamUsers(selectedTeamId)
// Parent handles presentation search filtering via computed
const filteredUsers = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
if (!query) return users.value
return users.value.filter(u => u.name.toLowerCase().includes(query))
})
function handleSelectUser(user: User) {
selectedUserId.value = user.id
}
</script>
<template>
<div>
<select v-model="selectedTeamId">
<option value="team-1">Engineering</option>
<option value="team-2">Product</option>
<option value="team-3">Design</option>
</select>
<input v-model="searchQuery" placeholder="Filter members..." />
<p v-if="error" class="error">{{ error.message }}</p>
<UserTable
:users="filteredUsers"
:loading="loading"
:selected-user-id="selectedUserId"
@select-user="handleSelectUser"
/>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Copying props into a local ref without recognizing that parent updates will not sync
- Allowing out-of-order network responses to overwrite newer user selections without cancellation or sequence tokens
- Adding deep: true to watch large arrays instead of watching scalar identifiers or using computed properties
- Mixing presentational props with internal fetching without a clear ownership contract
How do ref and reactive manage state updates in Vue 3, and what practical trade-offs guide choosing one over the other?
Direct Answer
Ref wraps any value in a reactive container requiring a dot value property in scripts but auto-unwraps in templates. Reactive directly proxies objects without dot value, but cannot hold primitives and loses reactivity upon destructuring. Ref is the standard convention for predictable reassignments.
Detailed Explanation
### Core Mechanics: ref vs. reactive
Vue 3's reactivity system is built on ES6 Proxy objects that intercept property reads (dependency tracking) and property writes (triggering effects). Vue offers two core primitives to create reactive state: ref and reactive.
---
### Comparison of Primitives
| Feature | ref(initialValue) | reactive(initialObject) |
| :--- | :--- | :--- |
| Acceptable Types | Primitives (number, string, boolean) and Objects | Objects, Arrays, Sets, Maps only (rejects primitives) |
| Data Structure | Object with .value getter/setter (RefImpl) | ES6 Proxy wrapping the target object directly |
| Script Access | Explicit .value required (count.value++) | Direct property access (state.count++) |
| Template Access | Top-level refs unwrap automatically | Direct property access |
| Reassignment | Reassigning .value preserves reactivity | Reassigning the variable breaks the proxy connection |
| Destructuring | Retains reactivity (each remains a Ref) | Loses reactivity unless wrapped with toRefs() |
`ts
import { ref, reactive, toRefs } from 'vue'
// 1. ref: holds primitives or objects
const count = ref(0)
count.value++ // Requires .value in script
const user = ref({ name: 'Jordan', role: 'admin' })
user.value = { name: 'Taylor', role: 'user' } // Complete reassignment works seamlessly
// 2. reactive: objects only
const state = reactive({
title: 'Dashboard',
unreadAlerts: 4
})
state.unreadAlerts++ // No .value needed
// Destructuring hazard:
// const { title } = state // BROKEN: 'title' is a plain string now!
const { title, unreadAlerts } = toRefs(state) // SAFE: converted to linked Ref objects
---
### Practical Trade-offs and Industry Conventions
1. The Reassignment Problem with `reactive`:
When fetching new data from an API, developers frequently attempt to replace an entire reactive object:
`ts
let userProfile = reactive({ name: '', email: '' })
// Later in an async callback:
userProfile = reactive(await fetchUser()) // BROKEN!
Reassigning userProfile overwrites the local reference with a new proxy, but components and template bindings still hold references to the *original* proxy. The UI stops updating.
With ref, reassigning userProfile.value = await fetchUser() safely triggers reactive notifications to all dependents.
2. Template Unwrapping Nuance:
Top-level properties returned from <script setup> or setup() that are refs unwrap automatically in templates: {{ count }}.
However, nested refs inside plain objects or array elements do not auto-unwrap:
`vue
<!-- If nested inside a plain object: const obj = { count: ref(0) } -->
<p>{{ obj.count.value }}</p> <!-- .value is required here! -->
3. Team Conventions:
Most modern production Vue teams standardize on ref() as the primary primitive for all state (primitives, arrays, and objects) because of its explicit .value access and resilient reassignment behavior, reserving reactive() for complex composite state where .value clutter is demonstrably counterproductive.
Code Example
<script setup lang="ts">
import { ref, reactive, toRefs } from 'vue'
interface Metric {
id: string
label: string
value: number
}
// Recommended: ref for primitives and replaceable arrays
const activeFilter = ref<'daily' | 'weekly'>('daily')
const metrics = ref<Metric[]>([])
const loading = ref(false)
// Grouped state using reactive
const formState = reactive({
teamName: '',
maxSeats: 10
})
// Safe destructuring using toRefs
const { teamName, maxSeats } = toRefs(formState)
async function loadMetrics() {
loading.value = true
try {
// Reassigning .value preserves all component and template tracking
metrics.value = [
{ id: 'm1', label: 'Page Views', value: 1240 },
{ id: 'm2', label: 'Conversions', value: 86 }
]
} finally {
loading.value = false
}
}
</script>
<template>
<div>
<button @click="loadMetrics" :disabled="loading">Load Metrics</button>
<p>Filter: {{ activeFilter }}</p>
<p>Team: {{ teamName }} (Seats: {{ maxSeats }})</p>
<ul>
<li v-for="m in metrics" :key="m.id">
{{ m.label }}: {{ m.value }}
</li>
</ul>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Reassigning a variable created with reactive directly instead of mutating its properties
- Destructuring reactive objects without toRefs, silently severing reactivity
- Forgetting .value in TypeScript/JavaScript methods when modifying refs
- Assuming template auto-unwrapping applies to refs deeply nested inside plain objects or arrays
How should Vue components share and synchronize state using props, emits, and v-model without causing uncontrolled two-way mutation?
Direct Answer
Parents retain state ownership by passing data down via props and listening to custom events via emits. Two-way binding with v-model standardizes this into modelValue props and update events. Children needing draft editing should copy props to local state and emit explicit saved values on submit.
Detailed Explanation
### Single Source of Truth in Component Hierarchies
Vue enforces a strict one-way data flow architecture:
emit).Directly mutating a prop inside a child component is an anti-pattern. If a child modifies a prop directly, Vue emits a console warning in development, and any subsequent re-render of the parent component will re-evaluate its own state and overwrite the child's local changes.
---
### v-model: Structured Two-Way Binding
v-model provides clean syntactic sugar over the one-way data flow pattern without introducing uncontrolled shared mutation.
#### Vue 3 v-model Desugaring
When used on a custom component:
`vue
<UserEditor v-model="userName" />
It compiles directly to:
`vue
<UserEditor
:modelValue="userName"
@update:modelValue="val => userName = val"
/>
#### Vue 3.4+ defineModel() Macro
In Vue 3.4, the defineModel() compiler macro simplifies component two-way binding while maintaining explicit parent ownership under the hood:
`vue
<!-- UserEditor.vue (Child) -->
<script setup lang="ts">
// Declares 'modelValue' prop and 'update:modelValue' emit automatically
const model = defineModel<string>({ required: true })
</script>
<template>
<input v-model="model" />
</template>
---
### Handling Local Draft State vs. Immediate Synchronization
A common dilemma is whether child edits should immediately reflect in the parent or remain isolated until an explicit user action (e.g., clicking "Save").
1. Immediate Controlled Binding (Search Inputs, Sliders):
Use v-model or @update:modelValue so the parent state updates in real time on every input event.
2. Draft Editing (Modal Forms, Settings Dialogs):
If the user can cancel an edit, child components should copy the initial prop into a local reactive ref or reactive object upon mounting:
submit or save event with the drafted payload.Code Example
<!-- TeamSettingsDialog.vue (Controlled Draft Pattern) -->
<script setup lang="ts">
import { ref, watch } from 'vue'
export interface TeamProfile {
name: string
tier: 'free' | 'pro' | 'enterprise'
}
const props = defineProps<{
initialProfile: TeamProfile
isOpen: boolean
}>()
const emit = defineEmits<{
(e: 'save', updated: TeamProfile): void
(e: 'close'): void
}>()
// Isolated local draft state prevents premature parent mutation
const draft = ref<TeamProfile>({ ...props.initialProfile })
// Synchronize local draft if parent changes initialProfile externally
watch(
() => props.initialProfile,
(newProfile) => {
draft.value = { ...newProfile }
},
{ deep: true }
)
function handleSave() {
emit('save', { ...draft.value })
emit('close')
}
function handleCancel() {
draft.value = { ...props.initialProfile } // Reset draft
emit('close')
}
</script>
<template>
<dialog :open="isOpen" class="modal">
<h3>Edit Team Profile</h3>
<label>
Team Name:
<input v-model="draft.name" />
</label>
<label>
Tier:
<select v-model="draft.tier">
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<div class="actions">
<button type="button" @click="handleCancel">Cancel</button>
<button type="button" @click="handleSave">Save Changes</button>
</div>
</dialog>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Mutating object props directly (e.g., props.user.name = "Alice") bypassing parent event listeners
- Assuming v-model creates bidirectional memory links rather than standard prop-and-event sugar
- Forgetting to reset or synchronize local draft state when modal dialogs reopen with different props
- Using v-model for complex multi-field form dialogs when cancellation requires draft isolation
When should a Vue developer use computed versus watch or watchEffect, and how should side effects, dependency tracking, and cleanup be handled?
Direct Answer
Computed derives cached, pure values with zero side effects. Watch observes specific reactive sources to run imperative side effects like network requests, receiving old and new values. WatchEffect tracks dependencies automatically and is suited for immediate DOM or API synchronization.
Detailed Explanation
### Mental Models: Pure Derivation vs. Imperative Side Effects
Vue provides three core primitives for reacting to state changes. Choosing the incorrect primitive leads to duplicate renders, unnecessary network requests, or stale caches.
---
### Comparison Matrix
| Primitive | Primary Purpose | Execution Timing | Dependency Tracking | Output & Side Effects |
| :--- | :--- | :--- | :--- | :--- |
| `computed()` | Derived reactive state | Lazy & Cached (only evaluates when read) | Automatic (tracks refs accessed in getter) | Must be pure; returns a read-only ref; zero side effects |
| `watch()` | Imperative side effects | Lazy by default (runs only when source changes) | Explicit source (must pass ref, getter, or array) | Runs side effects (HTTP, DOM, timers); receives (newVal, oldVal) |
| `watchEffect()` | Immediate side effects | Immediate (runs once on mount, then on changes) | Automatic (tracks every reactive value accessed inside) | Runs side effects; no old value access; suited for coupled DOM sync |
---
### 1. computed: Pure Derived State
A computed property computes a value from existing reactive dependencies.
`ts
// GOOD: Pure derivation
const filteredProducts = computed(() => {
return products.value.filter(p => p.category === selectedCategory.value)
})
// BAD: Side effect in computed getter!
const user = computed(async () => {
return await fetchUser(userId.value) // ANTI-PATTERN: computed getters must be synchronous and pure
})
---
### 2. watch: Precise Explicit Side Effects
Use watch when you need to perform an asynchronous side effect in response to specific state changes, or when you need access to both the new and previous values.
`ts
import { watch } from 'vue'
// Explicit source using a getter function:
watch(
() => props.userId,
async (newId, oldId, onCleanup) => {
if (!newId) return
const controller = new AbortController()
onCleanup(() => controller.abort()) // Clean up previous request if ID changes rapidly
try {
userData.value = await api.getUser(newId, { signal: controller.signal })
} catch (err: any) {
if (err.name !== 'AbortError') handleError(err)
}
},
{ immediate: true } // Execute on component mount if needed
)
---
### 3. watchEffect: Immediate Automatic Side Effects
watchEffect executes immediately upon declaration and automatically tracks every reactive ref or property accessed during its execution.
watchEffect can trigger unintended re-executions.---
### Managing Cleanup with onCleanup
Both watch and watchEffect provide an onCleanup callback parameter. This function runs:
1. Immediately before the watcher re-executes because a dependency changed.
2. Automatically when the owning component instance is unmounted.
It is critical for preventing memory leaks, clearing setTimeout intervals, closing WebSocket connections, and aborting in-flight HTTP requests via AbortController.
Code Example
<script setup lang="ts">
import { ref, computed, watch, watchEffect, onUnmounted } from 'vue'
const searchKeyword = ref('')
const selectedTag = ref('all')
const results = ref<string[]>([])
const isSearching = ref(false)
// 1. COMPUTED: Pure derived summary
const filterSummary = computed(() => {
return `Query: "${searchKeyword.value}" | Tag: ${selectedTag.value}`
})
// 2. WATCH: Explicit side effect with request cancellation
watch(
[searchKeyword, selectedTag],
async ([newQuery, newTag], _, onCleanup) => {
if (!newQuery.trim()) {
results.value = []
return
}
const abortController = new AbortController()
onCleanup(() => {
// Aborts prior request if query or tag changes before fetch resolves
abortController.abort()
})
isSearching.value = true
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(newQuery)}&tag=${newTag}`,
{ signal: abortController.signal }
)
results.value = await response.json()
} catch (err: any) {
if (err.name !== 'AbortError') {
console.error('Search request failed', err)
}
} finally {
if (!abortController.signal.aborted) {
isSearching.value = false
}
}
}
)
// 3. WATCHEFFECT: Direct DOM synchronization
watchEffect(() => {
document.title = searchKeyword.value
? `Search: ${searchKeyword.value} (${results.value.length})`
: 'Product Catalog'
})
</script>
<template>
<div>
<input v-model="searchKeyword" placeholder="Search..." />
<select v-model="selectedTag">
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
<p>{{ filterSummary }}</p>
<p v-if="isSearching">Loading results...</p>
<ul>
<li v-for="item in results" :key="item">{{ item }}</li>
</ul>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Using a watcher to synchronize derived state into a secondary ref instead of using computed
- Performing asynchronous network requests or mutating external state inside a computed getter
- Failing to clean up timers, event listeners, or abort in-flight requests via onCleanup
- Using watchEffect with implicit dependencies when only a specific scalar property change should trigger the effect
How do you determine the boundaries between local component state, composable state, and global Pinia stores, and what risks arise from over-centralizing state?
Direct Answer
Local state belongs inside components when tied to ephemeral UI. Composables encapsulate reusable, multi-instance reactive logic. Pinia is reserved for shared application state spanning disjoint routes or features. Centralizing all state into Pinia causes unnecessary coupling and cache staleness.
Detailed Explanation
### State Ownership Boundaries
A robust Vue architecture divides state into three distinct tiers based on lifetime, ownership, and scope:
┌────────────────────────────────────────────────────────┐
│ 1. Local Component State (ref / reactive in SFC) │
│ - Ephemeral UI: dropdown open, tab index, hover │
│ - Lifetime: bound to component mount/unmount │
└────────────────────────────────────────────────────────┘
│
┌────────────────────────────────────────────────────────┐
│ 2. Composable Feature State (useFeature() instance) │
│ - Reusable logic: pagination, form validation │
│ - Lifetime: per caller or scoped feature hierarchy │
└────────────────────────────────────────────────────────┘
│
┌────────────────────────────────────────────────────────┐
│ 3. Global Application Store (Pinia defineStore()) │
│ - Cross-cutting: session auth, permissions, cart │
│ - Lifetime: whole application session / SPA lifecycle│
└────────────────────────────────────────────────────────┘
---
### When to Use Each Tier
1. Local Component State (`ref`, `reactive`):
2. Composable State (`use...()`):
3. Global Pinia Store (`defineStore`):
---
### Risks of Over-Centralizing State into Global Stores
1. Memory Leaks and Stale Cache Bugs:
Components unmount, but Pinia stores persist for the entire SPA lifetime. Storing component-specific query results or form drafts in a global store leaves zombie data that corrupts the UI when users navigate back to the screen later.
2. Coupling and Brittle Testing:
Components that rely on a global store for trivial UI state cannot be tested in isolation without mocking the entire store and its dependencies.
3. Loss of Parallel Instance Support:
If a dashboard renders two <ProjectCard> widgets simultaneously, storing "active project" in a single unkeyed global store variable causes the two widgets to overwrite each other's state.
---
### Pinia Reactivity: The storeToRefs Requirement
When consuming state or getters from a Pinia store, destructuring properties directly strips their reactive proxy connection:
`ts
import { useAuthStore } from '@/stores/auth'
import { storeToRefs } from 'pinia'
const authStore = useAuthStore()
// BAD: Breaks reactivity! 'user' and 'isAuthenticated' become static values
// const { user, isAuthenticated } = authStore
// GOOD: Preserves reactive tracking via storeToRefs
const { user, isAuthenticated } = storeToRefs(authStore)
// Actions can be destructured directly since methods don't need reactive wrapping
const { login, logout } = authStore
Code Example
// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface CartItem {
id: string
name: string
price: number
quantity: number
}
// Global store: genuine shared state across header, checkout, and product catalog
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const totalItems = computed(() => {
return items.value.reduce((sum, item) => sum + item.quantity, 0)
})
const totalPrice = computed(() => {
return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
})
function addItem(product: { id: string; name: string; price: number }) {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function clearCart() {
items.value = []
}
return { items, totalItems, totalPrice, addItem, clearCart }
})STAR Method Blueprint
Common Interview Pitfalls
- Placing ephemeral UI state like modal visibility or tab indexes into global Pinia stores
- Destructuring state or getters from a Pinia store without storeToRefs, breaking reactivity
- Treating global stores as an authoritative database rather than a client cache of server state
- Failing to reset global store state on user logout, creating cross-tenant session leaks
What architectural principles govern Vue 3 composables, and how do you prevent accidental shared state, memory leaks, and lifecycle errors across consumers?
Direct Answer
Composables encapsulate stateful logic into functions returning reactive state and methods. Defining refs inside the function ensures per-instance isolation, while module-level refs create singletons. Lifecycle hooks in composables require active component setup context for proper disposal.
Detailed Explanation
### What is a Composable?
A composable is a function that leverages the Vue Composition API to encapsulate and reuse stateful logic. By convention, composable names start with use (e.g., useMouse, useFetch, usePagination).
Unlike simple utility functions (which only calculate pure outputs from inputs), a composable manages internal reactive state (ref, computed) and hooks into component lifecycle events (onMounted, onUnmounted).
---
### Per-Instance State vs. Module-Scoped Shared State
The physical placement of reactive declarations determines whether consumers share state:
#### 1. Per-Instance State (Default & Standard):
`ts
// State declared INSIDE the function:
export function useCounter() {
const count = ref(0) // New RefImpl instance created for every caller
function increment() { count.value++ }
return { count, increment }
}
Every component calling useCounter() receives an isolated count variable.
#### 2. Module-Scoped Shared State (Singleton Pattern):
`ts
// State declared OUTSIDE the function (module level):
const globalCount = ref(0) // Single RefImpl shared across all importers
export function useGlobalCounter() {
function increment() { globalCount.value++ }
return { count: globalCount, increment }
}
Every component calling useGlobalCounter() shares the exact same reactive instance. While intentional for lightweight singletons (like dark/light theme or network status), doing this unintentionally creates critical cross-component contamination bugs.
---
### Lifecycle Context and the Setup Scope
Vue's lifecycle hooks (onMounted, onUnmounted, watchEffect) rely on an internal current component instance pointer (getCurrentInstance()).
<script setup> or setup().setTimeout, await fetch()), getCurrentInstance() returns null, and Vue logs a warning: onMounted() is called when there is no active component instance to be associated with.---
### Composable vs. React Hook Differences
Engineers coming from React must recognize that Vue composables operate under fundamentally different execution mechanics:
1. Execution Count: A Vue component's setup() runs only once when the component is created. A React functional component re-executes its entire body on every state update.
2. Conditional Invocation: Vue composables can be invoked inside if statements or loops without breaking reactivity, provided any internal lifecycle hooks are attached synchronously during setup. (React strictly forbids hooks in conditionals).
3. No Dependency Arrays: Vue automatically tracks dependencies through proxy property access, eliminating manual hook dependency arrays ([deps]).
---
### Preventing Resource Leaks in Composables
When a composable attaches DOM event listeners, starts intervals, or subscribes to web sockets, it must clean them up in onUnmounted:
`ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useEventListener(
target: EventTarget | Window,
event: string,
handler: (e: Event) => void
) {
onMounted(() => target.addEventListener(event, handler))
onUnmounted(() => target.removeEventListener(event, handler))
}
Code Example
// composables/useWindowScroll.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowScroll() {
// 1. Per-instance reactive state
const scrollX = ref(0)
const scrollY = ref(0)
function update() {
scrollX.value = window.scrollX
scrollY.value = window.scrollY
}
// 2. Lifecycle hooks attached synchronously to calling component
onMounted(() => {
update()
window.addEventListener('scroll', update, { passive: true })
})
// 3. Strict cleanup prevents memory leaks upon unmount
onUnmounted(() => {
window.removeEventListener('scroll', update)
})
return { scrollX, scrollY }
}STAR Method Blueprint
Common Interview Pitfalls
- Declaring refs outside the composable function by accident, causing multiple views to overwrite shared state
- Calling composables that register lifecycle hooks inside async callbacks where setup context is lost
- Forgetting to remove DOM listeners, timers, or WebSocket subscriptions in onUnmounted
- Falsely applying React hook constraints (like avoiding conditionals) to Vue composable logic
A developer moved reactive state outside useProjects() into module scope, and multiple dashboard views now share the same project list, overlapping team fetches overwrite newer data with older responses, and watchers cause synchronization feedback loops. How do you diagnose this leak, handle async races, and architect data ownership between Pinia and composables?
Direct Answer
Module-scoped refs turned useProjects into an accidental shared singleton, while unkeyed state, uncancelled async requests, and broad watchers caused cross-team data leaks. The fix isolates composable state per consumer, keys store data by team, and cancels in-flight network fetches.
Detailed Explanation
### Root Cause Breakdown
This production failure represents an architectural breakdown across five interacting dimensions:
1. Unintentional Module-Scoped Singleton State:
Moving const projects = ref([]) outside the useProjects() function converts it into an ES module singleton. Every component that imports useProjects() now binds to the exact same reactive memory reference. When View A navigates to Team A and View B displays Team B, they mutate and overwrite each other's project array.
2. Async Race Conditions Across Team Transitions:
When navigating rapidly:
$$\text{Team A (slow: 800ms)} \rightarrow \text{Team B (fast: 120ms)}$$
3. Loading Flag Race Condition:
With multiple views or rapid team switches sharing a single const loading = ref(false):
loading = true).loading = true).loading = false, while View A is still in flight!4. Multi-Tenant Security and Privacy Leakage:
If teams represent distinct organizational or customer tenants, displaying Team A's cached projects to a user viewing Team B constitutes a critical cross-tenant data leak.
5. Watcher Synchronization Feedback Loops:
Engineers attempted to fix stale state by adding watchers to "keep everything in sync":
selectedTeamId and calls loadProjects.projects and mutates store state.---
### Architectural Redesign & Decision Framework
#### Strategy A: Per-View Independent Composable (Decoupled Views)
If each dashboard view needs an independent lifecycle (unmounting clears state, separate loading indicators):
AbortController to abort prior requests when teamId changes.#### Strategy B: Keyed Pinia Feature Store (Shared Multi-View Cache)
If multiple views legitimately need to display and share the same team's projects simultaneously:
teamId (projectsByTeam: Record<string, Project[]>) rather than storing a single global array.loadingByTeam: Record<string, boolean>).---
### Production Implementation: Robust Keyed Pinia Store
`ts
// stores/projects.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Project {
id: string
name: string
teamId: string
}
export const useProjectsStore = defineStore('projects', () => {
// 1. Keyed storage prevents cross-team data contamination
const projectsByTeam = ref<Record<string, Project[]>>({})
const loadingByTeam = ref<Record<string, boolean>>({})
const errorByTeam = ref<Record<string, string | null>>({})
// Active AbortControllers keyed by teamId
const inFlightControllers = new Map<string, AbortController>()
async function fetchTeamProjects(teamId: string, forceRefresh = false) {
if (!teamId) return
// Return cached data if already loaded and refresh not forced
if (projectsByTeam.value[teamId] && !forceRefresh) {
return projectsByTeam.value[teamId]
}
// Abort existing in-flight request for this specific team
if (inFlightControllers.has(teamId)) {
inFlightControllers.get(teamId)!.abort()
}
const controller = new AbortController()
inFlightControllers.set(teamId, controller)
loadingByTeam.value[teamId] = true
errorByTeam.value[teamId] = null
try {
const response = await fetch(/api/teams/${teamId}/projects, {
signal: controller.signal
})
if (!response.ok) throw new Error(Failed to fetch projects (${response.status}))
const data: Project[] = await response.json()
projectsByTeam.value[teamId] = data
return data
} catch (err: any) {
if (err.name === 'AbortError') return // Normal cancellation
errorByTeam.value[teamId] = err.message || 'Unknown network error'
throw err
} finally {
loadingByTeam.value[teamId] = false
inFlightControllers.delete(teamId)
}
}
function clearStore() {
// Abort all active requests and purge on logout or tenant switch
inFlightControllers.forEach(ctrl => ctrl.abort())
inFlightControllers.clear()
projectsByTeam.value = {}
loadingByTeam.value = {}
errorByTeam.value = {}
}
return {
projectsByTeam,
loadingByTeam,
errorByTeam,
fetchTeamProjects,
clearStore
}
})
---
### Consumer Composable with Automatic Cleanup
`ts
// composables/useCurrentTeamProjects.ts
import { computed, watch, onUnmounted, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useProjectsStore } from '@/stores/projects'
export function useCurrentTeamProjects(teamIdRef: Ref<string>) {
const store = useProjectsStore()
const { projectsByTeam, loadingByTeam, errorByTeam } = storeToRefs(store)
// Derived state automatically react to team changes
const projects = computed(() => projectsByTeam.value[teamIdRef.value] ?? [])
const loading = computed(() => loadingByTeam.value[teamIdRef.value] ?? false)
const error = computed(() => errorByTeam.value[teamIdRef.value] ?? null)
// Watch selected team and load on demand
watch(
teamIdRef,
(newTeamId) => {
if (newTeamId) {
store.fetchTeamProjects(newTeamId)
}
},
{ immediate: true }
)
return { projects, loading, error, refresh: () => store.fetchTeamProjects(teamIdRef.value, true) }
}
---
### Verification and Regression Testing
1. Isolation Test: Mount two separate components calling useCurrentTeamProjects. Pass Team A to Component 1 and Team B to Component 2. Verify Component 1 only renders Team A projects and Component 2 only renders Team B projects.
2. Race Condition Test: Simulate Team A responding in 500ms and Team B responding in 100ms. Navigate from Team A to Team B. Verify the view resolves to Team B and ignores Team A's delayed resolution.
3. Session Reset Test: Call clearStore() on user logout. Verify all in-flight requests abort and memory references are completely cleared.
Code Example
// Component Usage in DashboardView.vue
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useCurrentTeamProjects } from '@/composables/useCurrentTeamProjects'
const route = useRoute()
// Single source of truth: team ID derived directly from the route param
const teamId = computed(() => (route.params.teamId as string) || '')
const { projects, loading, error, refresh } = useCurrentTeamProjects(teamId)
</script>
<template>
<div class="projects-container">
<h2>Team Projects</h2>
<button @click="refresh" :disabled="loading">Refresh</button>
<div v-if="loading" class="spinner">Loading projects...</div>
<div v-else-if="error" class="error-banner">{{ error }}</div>
<ul v-else-if="projects.length > 0">
<li v-for="project in projects" :key="project.id">
{{ project.name }}
</li>
</ul>
<p v-else>No projects found for this team.</p>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Declaring reactive state outside composables without realizing it creates a cross-view singleton
- Using a single unkeyed array in a global store for resources that depend on a tenant or team ID
- Creating cyclic watcher chains to synchronize multiple competing sources of truth
- Failing to abort in-flight HTTP requests when components unmount or route parameters change
How does Vue Router manage route definitions, navigation, params, and links in Vue 3, and how do client routes differ from server boundaries?
Direct Answer
Vue Router maps browser URLs to component trees using RouterView and RouterLink for client navigation without full reloads. Route params define path segments while query params handle optional query strings. Client routes guide user flow but provide zero backend authorization security.
Detailed Explanation
### Vue Router Fundamentals in Vue 3
Vue Router is the official client-side routing library for Vue. It synchronizes the browser's URL with Vue's reactive component hierarchy using HTML5 History mode (createWebHistory) or Hash mode (createWebHashHistory).
---
### Core Building Blocks
1. `<RouterView />`:
<RouterView /> outlets inside layout components.2. `<RouterLink>`:
<a> tag with proper href, but intercepts click events to prevent full-page browser reloads.router-link-active and router-link-exact-active) for navigational styling.<a> tag, <RouterLink> performs client-side transitions via the History API (pushState), preserving single-page application state and memory.3. Route Parameters vs. Query Parameters:
/users/:id). They form the core identity of the resource being displayed and are required for the route to match./users?sort=asc&page=2). They specify filters, pagination, or presentation modifiers without changing resource identity.`ts
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{
path: '/teams/:teamId',
name: 'team-details',
component: () => import('@/views/TeamDetailsView.vue'),
props: true // Pass route.params.teamId directly as component props!
}
]
})
---
### Programmatic Navigation via useRouter
In the Composition API, programmatic navigation uses the useRouter() composable:
`vue
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
function navigateToTeam(id: string) {
// Navigate by named route and params
router.push({
name: 'team-details',
params: { teamId: id },
query: { tab: 'members' }
})
}
</script>
---
### Client-Side Routes $
eq$ Server Security Boundaries
A fundamental architectural pitfall is treating client-side route configuration as access control:
Code Example
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// Accessing reactive route params and query parameters
const userId = computed(() => route.params.id as string)
const currentTab = computed(() => (route.query.tab as string) || 'overview')
function changeTab(newTab: string) {
router.push({
query: { ...route.query, tab: newTab }
})
}
function returnToDirectory() {
router.push({ name: 'users-directory' })
}
</script>
<template>
<div class="user-view">
<nav class="breadcrumb">
<RouterLink :to="{ name: 'users-directory' }">All Users</RouterLink>
<span>/</span>
<span>User #{{ userId }}</span>
</nav>
<h2>Viewing User: {{ userId }}</h2>
<div class="tabs">
<button
:class="{ active: currentTab === 'overview' }"
@click="changeTab('overview')"
>
Overview
</button>
<button
:class="{ active: currentTab === 'settings' }"
@click="changeTab('settings')"
>
Settings
</button>
</div>
<button type="button" @click="returnToDirectory">Back to Directory</button>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Confusing route params (/users/:id) with query strings (/users?id=123)
- Using raw <a href="..."> links instead of <RouterLink>, causing full page reloads and state loss
- Believing client-side route guards replace server-side API authorization and security controls
- Assuming route params update synchronously within the exact same JavaScript microtask as router.push
How should a Vue developer build, validate, and submit forms using v-model and local state, and why is client validation insufficient for security?
Direct Answer
Forms bind inputs to reactive state using v-model and handle submission via submit prevent handlers. Client validation guides users and prevents invalid network round-trips with immediate feedback, but server validation remains mandatory because clients can be bypassed using direct HTTP requests.
Detailed Explanation
### Form Management Architecture in Vue 3
Form handling in Vue 3 centers on reactive data binding, immediate client feedback, and structured submission lifecycles.
---
### Key Components of a Robust Form
1. Reactive Form Model:
Group related form fields into a typed reactive object (reactive) or explicit ref containers to manage input bindings cleanly.
2. Modifier Capabilities:
Vue provides built-in v-model modifiers that sanitize input automatically:
v-model.trim: Strips leading and trailing whitespace.v-model.number: Typecasts input strings to JavaScript numbers.v-model.lazy: Synchronizes state on change events (e.g., input blur) instead of on every keystroke (input event).3. Client-Side Validation vs. Server-Side Validation:
4. Submission Lifecycle:
Form submission should always handle three primary states:
Code Example
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
interface RegistrationForm {
email: string
fullName: string
acceptedTerms: boolean
}
const form = reactive<RegistrationForm>({
email: '',
fullName: '',
acceptedTerms: false
})
const isSubmitting = ref(false)
const serverErrors = ref<Record<string, string>>({})
const submitSuccess = ref(false)
// Computed client validation
const clientErrors = computed(() => {
const errors: Record<string, string> = {}
if (!form.email.includes('@')) {
errors.email = 'Please provide a valid email address.'
}
if (form.fullName.trim().length < 2) {
errors.fullName = 'Full name must be at least 2 characters.'
}
if (!form.acceptedTerms) {
errors.acceptedTerms = 'You must accept the terms of service.'
}
return errors
})
const isValid = computed(() => Object.keys(clientErrors.value).length === 0)
async function handleSubmit() {
if (!isValid.value || isSubmitting.value) return
isSubmitting.value = true
serverErrors.value = {}
try {
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
if (!res.ok) {
if (res.status === 422) {
const data = await res.json()
serverErrors.value = data.errors || { general: 'Validation failed.' }
} else {
serverErrors.value = { general: 'Server error. Please try again.' }
}
return
}
submitSuccess.value = true
} catch (err) {
serverErrors.value = { general: 'Network connection failed.' }
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="register-form" novalidate>
<div v-if="serverErrors.general" class="error-banner">
{{ serverErrors.general }}
</div>
<label>
Full Name:
<input v-model.trim="form.fullName" type="text" />
<span v-if="clientErrors.fullName" class="field-error">{{ clientErrors.fullName }}</span>
<span v-if="serverErrors.fullName" class="field-error">{{ serverErrors.fullName }}</span>
</label>
<label>
Email:
<input v-model.trim="form.email" type="email" />
<span v-if="clientErrors.email" class="field-error">{{ clientErrors.email }}</span>
<span v-if="serverErrors.email" class="field-error">{{ serverErrors.email }}</span>
</label>
<label>
<input v-model="form.acceptedTerms" type="checkbox" />
I accept the Terms and Conditions
</label>
<span v-if="clientErrors.acceptedTerms" class="field-error">{{ clientErrors.acceptedTerms }}</span>
<button type="submit" :disabled="isSubmitting || !isValid">
{{ isSubmitting ? 'Registering...' : 'Register' }}
</button>
</form>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Relying solely on client validation or a disabled submit button as a security measure
- Forgetting the .prevent event modifier on @submit, causing an unintentional full browser POST reload
- Failing to handle backend 422 validation response payloads and leaving users stranded with generic errors
- Allowing multiple in-flight submissions when users rapidly double-click the submit button
How should Vue Router navigation guards orchestrate transitions and authenticated flows, and what security boundaries must remain on the server?
Direct Answer
Navigation guards intercept route changes globally or per-route to redirect unauthenticated users and resolve initial state. However, route guards and metadata are strictly user interface flow controls; backend APIs must independently authenticate and authorize every request with secure tokens.
Detailed Explanation
### Navigation Guards Pipeline in Vue Router
Vue Router provides a rich lifecycle of navigation guards to intercept, redirect, or abort transitions. Guards run asynchronously in a defined sequence:
1. Global before guards (router.beforeEach)
2. Route config guards (beforeEnter)
3. In-component guards (beforeRouteEnter, beforeRouteUpdate, beforeRouteLeave)
4. Global resolve guards (router.beforeResolve)
5. Global after hooks (router.afterEach)
---
### Standard Navigation Guard Return Values (Vue 3)
In Vue Router 4, guards return a value rather than relying on the legacy next() callback:
return true (or undefined): Allows the navigation to proceed.return false: Aborts navigation and resets the browser URL to the previous route.return { name: 'login', query: { redirect: to.fullPath } }: Cancels current transition and redirects to a new route.`ts
router.beforeEach(async (to, from) => {
const authStore = useAuthStore()
// Ensure initial user session is loaded before evaluating route metadata
if (!authStore.isInitialized) {
await authStore.initializeSession()
}
// Evaluate route meta requirements
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return {
name: 'login',
query: { redirect: to.fullPath }
}
}
if (to.meta.requiredRole && !authStore.hasRole(to.meta.requiredRole as string)) {
return { name: 'forbidden' }
}
})
---
### Route Metadata (meta) Best Practices
The meta property on route records is the standard location to attach arbitrary route requirements:
`ts
{
path: '/billing',
component: BillingView,
meta: {
requiresAuth: true,
requiredRole: 'admin',
title: 'Subscription & Billing'
}
}
---
### Critical Architectural Boundary: Client Guard $
eq$ Server Security
A common architectural misconception is assuming that navigation guards provide application security:
1. Source Code Exposure: All JavaScript code in a Single-Page Application, including route definitions and navigation guards, is executed on client hardware. A user can open browser DevTools, edit Vue Router state, or execute router.push('/admin') in the console to force component rendering.
2. Component Templates are Not Secrets: An unauthenticated user might bypass a guard to see the visual layout of an admin dashboard, but they must never be able to see actual sensitive data.
3. Data Security is Exclusively Backend: The data rendered inside the admin view comes from API requests. The backend API must validate the session cookie or JWT authorization header on every single HTTP endpoint. If the user lacks permissions, the API returns 401 Unauthorized or 403 Forbidden, ensuring zero sensitive information leaks.
Code Example
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
export const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { guestOnly: true }
},
{
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminDashboard.vue'),
meta: { requiresAuth: true, requiredRole: 'superadmin' }
}
]
})
// Unified Global Navigation Guard
router.beforeEach(async (to) => {
const authStore = useAuthStore()
// 1. Resolve auth session if app freshly reloaded
if (!authStore.isInitialized) {
await authStore.fetchCurrentUser()
}
// 2. Redirect guests away from protected routes
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return {
name: 'login',
query: { redirect: to.fullPath }
}
}
// 3. Prevent authenticated users from visiting login page
if (to.meta.guestOnly && authStore.isAuthenticated) {
return { name: 'dashboard' }
}
// 4. Client role check (improves UX; API still independently enforces)
if (to.meta.requiredRole && authStore.role !== to.meta.requiredRole) {
return { name: 'unauthorized' }
}
return true
})STAR Method Blueprint
Common Interview Pitfalls
- Treating client-side navigation guards as an adequate security boundary for backend data protection
- Forgetting to await auth initialization inside beforeEach on initial hard page refreshes
- Mixing the legacy next() callback with return values in Vue Router 4, causing subtle navigation hangs
- Creating infinite redirect loops by redirecting to a route that itself requires authentication
How should Vue applications orchestrate asynchronous data fetching while preventing race conditions, stale response overwrites, and unmount leaks?
Direct Answer
Data fetching requires pairing loading, error, and data states with request cancellation via AbortController or generation tokens. When route params change rapidly or components unmount, in-flight requests must be aborted or ignored to ensure slower prior responses never overwrite current data.
Detailed Explanation
### The Asynchronous Data Fetching Lifecycle
Data fetching in Single-Page Applications is inherently non-deterministic. Network latencies fluctuate, components unmount while promises are pending, and users navigate quickly across parameter routes.
A robust data fetching architecture must solve four fundamental challenges:
1. Tri-State Management (Loading, Error, Data)
2. Async Race Conditions Across Parameter Changes
3. Preventing Leaks and State Mutations on Unmounted Components
4. Distinguishing User Intent from Network Arrival Order
---
### The Race Condition Problem
Consider a route displaying project details by ID: /projects/:id.
A user clicks:
$$\text{Project 1 (latency: 800ms)} \rightarrow \text{Project 2 (latency: 150ms)}$$
1. Request for Project 1 fires at $t=0\text{ms}$.
2. User clicks Project 2 at $t=50\text{ms}$. Request 2 fires.
3. Request 2 completes at $t=200\text{ms}$. UI renders Project 2.
4. Request 1 completes at $t=800\text{ms}$. Slower response resolves and overwrites the UI with Project 1 data, while the URL still displays /projects/2.
Core Principle: The latest network response received is not guaranteed to represent the user's latest intent.
---
### Remediation Strategies
#### 1. Native Cancellation via AbortController
The cleanest solution aborts the in-flight HTTP request when dependencies change or the component unmounts:
`ts
import { watch, onUnmounted } from 'vue'
let controller: AbortController | null = null
watch(
() => route.params.id,
async (newId, _, onCleanup) => {
// Abort prior in-flight request
if (controller) controller.abort()
controller = new AbortController()
// Register watcher cleanup hook
onCleanup(() => controller?.abort())
try {
data.value = await api.getProject(newId, { signal: controller.signal })
} catch (err: any) {
if (err.name === 'AbortError') return // Normal cancellation
error.value = err.message
}
},
{ immediate: true }
)
#### 2. Request Sequence Tokens (Generation Counter)
If an underlying transport does not support AbortController, use a monotonically increasing sequence counter:
`ts
let latestSeq = 0
async function fetchProject(id: string) {
const currentSeq = ++latestSeq
loading.value = true
try {
const res = await api.getProject(id)
// Only apply data if this request matches the latest user intent
if (currentSeq === latestSeq) {
data.value = res
}
} finally {
if (currentSeq === latestSeq) {
loading.value = false
}
}
}
---
### Unmount Protection
When a component unmounts, any asynchronous promise running in the background will still resolve. While modern Vue 3 garbage collection disposes of template bindings, assigning to component-owned refs after unmount wastes CPU and can retain memory. Utilizing onUnmounted(() => controller?.abort()) guarantees prompt network disposal.
Code Example
// composables/useAsyncResource.ts
import { ref, watch, onUnmounted, type Ref } from 'vue'
export function useAsyncResource<T>(
sourceRef: Ref<string>,
fetcher: (id: string, signal: AbortSignal) => Promise<T>
) {
const data = ref<T | null>(null) as Ref<T | null>
const loading = ref(false)
const error = ref<string | null>(null)
let abortController: AbortController | null = null
watch(
sourceRef,
async (id, _, onCleanup) => {
if (abortController) {
abortController.abort()
}
if (!id) {
data.value = null
loading.value = false
error.value = null
return
}
abortController = new AbortController()
const signal = abortController.signal
onCleanup(() => {
abortController?.abort()
})
loading.value = true
error.value = null
try {
const result = await fetcher(id, signal)
data.value = result
} catch (err: any) {
if (err.name === 'AbortError') return
error.value = err.message || 'Failed to load resource.'
} finally {
if (!signal.aborted) {
loading.value = false
}
}
},
{ immediate: true }
)
onUnmounted(() => {
abortController?.abort()
})
return { data, loading, error }
}STAR Method Blueprint
Common Interview Pitfalls
- Assuming that when a component unmounts, its in-flight fetch requests are automatically cancelled
- Failing to handle out-of-order promise resolution when switching IDs, displaying stale data
- Using a single loading boolean without checking if an older finished request prematurely toggles it off
- Treating AbortError as a catastrophic failure and showing error banners to users upon normal cancellation
How should a Vue application structure its API client, service layer, and composables to separate transport mechanics from UI state?
Direct Answer
A centralized API client handles base URLs, auth headers, and normalized errors. Domain services define endpoint calls, while composables manage reactive state, loading, and lifecycle integration. This separates transport from UI components and avoids treating stores as duplicate databases.
Detailed Explanation
### Clean API Architecture in Vue 3 Applications
Scattering raw fetch or axios calls directly inside Vue component templates creates code that is brittle, untestable, and impossible to maintain. A scalable Vue architecture establishes clear boundaries across three distinct layers:
┌────────────────────────────────────────────────────────┐
│ 1. UI Components (<UserView>, <BillingModal>) │
│ - Template rendering, user events, UI-only state │
└────────────────────────────────────────────────────────┘
│ consumes
┌────────────────────────────────────────────────────────┐
│ 2. Feature Composables (useUsers(), useBilling()) │
│ - Reactive state (ref, computed), loading/errors, │
│ pagination, lifecycle hooks & request orchestration│
└────────────────────────────────────────────────────────┘
│ delegates to
┌────────────────────────────────────────────────────────┐
│ 3. Domain API Services (userService, billingService) │
│ - Endpoint definitions, payload typing, query params│
└────────────────────────────────────────────────────────┘
│ executes via
┌────────────────────────────────────────────────────────┐
│ 4. HTTP Transport Client (apiClient) │
│ - Base URL, auth headers, JSON parsing, timeouts, │
│ error normalization, interceptors │
└────────────────────────────────────────────────────────┘
---
### Layer Responsibilities
1. HTTP Transport Client (`apiClient`):
Authorization: Bearer <token> headers, prepending base URLs, configuring timeouts, and intercepting responses.{ status, code, message, errors }).2. Domain Services (`services/users.ts`):
`ts
export const userService = {
getById: (id: string, signal?: AbortSignal) =>
apiClient.get<User>(/users/${id}, { signal }),
update: (id: string, data: UpdateUserDTO) =>
apiClient.put<User>(/users/${id}, data)
}
3. Feature Composables (`composables/useUser.ts`):
user = ref(null), loading = ref(false), error = ref(null), and request cancellation.4. UI Components (`views/UserProfile.vue`):
---
### Server State vs. Client State
A critical pitfall is dumping all fetched API responses into global Pinia stores.
Treating global stores as an in-memory replica of your server database causes stale synchronization bugs and massive memory footprints.
Code Example
// 1. api/client.ts (Transport Layer)
export interface ApiError {
status: number
message: string
validationErrors?: Record<string, string[]>
}
export async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('auth_token')
const headers = new Headers(options.headers || {})
headers.set('Content-Type', 'application/json')
if (token) headers.set('Authorization', `Bearer ${token}`)
const response = await fetch(url, { ...options, headers })
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const normalized: ApiError = {
status: response.status,
message: errorData.message || `Request failed with status ${response.status}`,
validationErrors: errorData.errors
}
throw normalized
}
return response.json()
}
// 2. services/customerService.ts (Domain Service Layer)
export interface Customer {
id: string
name: string
email: string
}
export const customerService = {
getById: (id: string, signal?: AbortSignal) =>
request<Customer>(`/api/customers/${id}`, { signal }),
update: (id: string, data: Partial<Customer>) =>
request<Customer>(`/api/customers/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
// 3. composables/useCustomer.ts (Vue Reactivity Layer)
import { ref } from 'vue'
export function useCustomer() {
const customer = ref<Customer | null>(null)
const loading = ref(false)
const error = ref<ApiError | null>(null)
async function loadCustomer(id: string, signal?: AbortSignal) {
loading.value = true
error.value = null
try {
customer.value = await customerService.getById(id, signal)
} catch (err: any) {
error.value = err as ApiError
} finally {
loading.value = false
}
}
return { customer, loading, error, loadCustomer }
}STAR Method Blueprint
Common Interview Pitfalls
- Inlining raw fetch/axios calls directly inside Vue components with hardcoded URLs and headers
- Dumping every API entity into global Pinia stores, creating stale client-side database duplicates
- Failing to normalize backend error responses into a consistent frontend error structure
- Coupling domain services directly to component instances rather than keeping services pure
On a customer edit route, rapid parameter switching causes delayed responses to overwrite current customer data, and saving submits previous customer edits to new customer URLs. Meanwhile, token expiration triggers multiple redirect loops. How would you diagnose and re-architect this system?
Direct Answer
The incident stems from decoupled draft state that reads live route params at submit, uncancelled async fetches that overwrite newer data, and uncoordinated 401 redirects. The fix binds draft models to specific customer IDs, cancels stale fetches, and centralizes error and session handling.
Detailed Explanation
### Production Incident Root Cause Analysis
This critical production failure stems from five intersecting architectural defects:
1. Stale Route/Data Race Condition:
watch(() => route.params.customerId, async (id) => { ... }) initiates network requests sequentially without cancellation./customers/A $\rightarrow$ /customers/B, Request A (slow: 900ms) resolves after Request B (fast: 150ms).customer.value and form with Customer A's data, while the browser URL reflects Customer B.2. Decoupled Form Draft and Route Identity (The Cross-Customer Save Bug):
api.updateCustomer(String(route.params.customerId), form).route.params.customerId (now "B") and submits Customer A's drafted name and email to Customer B's URL.3. Component Instance Reuse in Vue Router:
/customers/1 to /customers/2), Vue Router reuses the existing component instance by default for performance. Component state is not reset unless explicitly watched or keyed.4. Uncoordinated 401 Session Expiry (Redirect Storms):
router.push('/login') calls, creating redirect storms, losing the original target URL, and resetting auth store state repeatedly.5. Inconsistent Error Handling:
401 Unauthorized: Session expired (re-authenticate).403 Forbidden: Authenticated user lacks tenant permission.404 Not Found: Customer does not exist.409 Conflict: Concurrency/version conflict (record updated by another user).422 Unprocessable Entity: Field validation failure.---
### Step-by-Step Architectural Remediation
#### 1. Bind Form Draft Explicitly to Resource Identity
Never read live route.params at submit time. The draft model must explicitly store the target resource ID it was instantiated with:
`ts
interface CustomerDraft {
targetCustomerId: string
name: string
email: string
isDirty: boolean
}
If draft.targetCustomerId !== currentLoadedId, reject submission.
#### 2. Cancel In-Flight Requests on Route Navigation
Use AbortController linked to Vue Router's onBeforeRouteUpdate or watcher onCleanup. When the customer ID changes, abort prior fetches immediately.
#### 3. Protect Unsaved Changes via Navigation Guards
Use onBeforeRouteLeave and onBeforeRouteUpdate to warn users before discarding unsaved edits:
`ts
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
function confirmDiscard() {
if (isDirty.value) {
return window.confirm('You have unsaved changes. Discard them?')
}
return true
}
onBeforeRouteLeave(confirmDiscard)
onBeforeRouteUpdate(confirmDiscard)
#### 4. Coordinated 401 Single-Flight Handler
Implement a centralized session expiry coordinator to handle 401s without redirect storms:
`ts
// api/sessionCoordinator.ts
let isRedirecting = false
export function handleSessionExpired(redirectPath: string) {
if (isRedirecting) return // Single-flight protection!
isRedirecting = true
const authStore = useAuthStore()
authStore.logout()
router.push({
name: 'login',
query: { redirect: redirectPath }
}).finally(() => {
isRedirecting = false
})
}
#### 5. Normalized API Error Hierarchy
Classify errors centrally and provide tailored UX:
422: Map field errors to form inputs.409: Display conflict banner: "This record was updated elsewhere. Reload to view latest data."404: Render a dedicated Not Found state.403: Display permission denied message without kicking the user to login.Code Example
<!-- views/CustomerEditView.vue -->
<script setup lang="ts">
import { ref, reactive, computed, watch, onUnmounted } from 'vue'
import { useRoute, onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
import { customerService, type Customer, type ApiError } from '@/services/customerService'
const route = useRoute()
// 1. Authoritative server state
const loadedCustomer = ref<Customer | null>(null)
const loading = ref(false)
const error = ref<ApiError | null>(null)
const isSaving = ref(false)
// 2. Explicitly bound draft state
const draft = reactive({
customerId: '',
name: '',
email: ''
})
const isDirty = computed(() => {
if (!loadedCustomer.value) return false
return (
draft.name !== loadedCustomer.value.name ||
draft.email !== loadedCustomer.value.email
)
})
let abortController: AbortController | null = null
// 3. Isolated fetch lifecycle with cancellation
async function loadCustomer(id: string) {
if (abortController) abortController.abort()
abortController = new AbortController()
loading.value = true
error.value = null
try {
const data = await customerService.getById(id, abortController.signal)
loadedCustomer.value = data
// Initialize draft strictly bound to this customer ID
draft.customerId = data.id
draft.name = data.name
draft.email = data.email
} catch (err: any) {
if (err.name === 'AbortError') return
error.value = err as ApiError
} finally {
loading.value = false
}
}
watch(
() => route.params.customerId as string,
(newId) => {
if (newId) loadCustomer(newId)
},
{ immediate: true }
)
// 4. Safe Submit Handler: Validates draft matches currently loaded customer
async function handleSave() {
if (!loadedCustomer.value || draft.customerId !== loadedCustomer.value.id) {
alert('Security Error: Form data does not match the active customer.')
return
}
isSaving.value = true
try {
const updated = await customerService.update(draft.customerId, {
name: draft.name,
email: draft.email
})
loadedCustomer.value = updated
alert('Customer updated successfully.')
} catch (err: any) {
if (err.status === 409) {
alert('Conflict: Another user updated this record. Please refresh.')
} else {
error.value = err as ApiError
}
} finally {
isSaving.value = false
}
}
// 5. Unsaved change guard
function checkUnsaved() {
if (isDirty.value) {
return window.confirm('Discard unsaved edits?')
}
return true
}
onBeforeRouteLeave(checkUnsaved)
onBeforeRouteUpdate(checkUnsaved)
onUnmounted(() => abortController?.abort())
</script>
<template>
<div class="customer-editor">
<div v-if="loading" class="spinner">Loading customer data...</div>
<div v-else-if="error" class="error-panel">
<h3>Error ({{ error.status }})</h3>
<p>{{ error.message }}</p>
</div>
<form v-else-if="loadedCustomer" @submit.prevent="handleSave">
<h2>Edit Customer: {{ loadedCustomer.name }}</h2>
<label>
Name:
<input v-model="draft.name" />
</label>
<label>
Email:
<input v-model="draft.email" />
</label>
<div class="actions">
<button type="submit" :disabled="isSaving || !isDirty">
{{ isSaving ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Reading route.params.customerId directly inside save() rather than submitting against the draft customerId
- Failing to abort in-flight customer fetches when switching routes, allowing stale responses to overwrite active views
- Allowing multiple concurrent 401 responses to trigger cascading redirect loops to the login page
- Proposing full page reloads on every navigation as a workaround for component reuse and race bugs
How should a Vue developer test component public behavior without coupling tests to internal implementation details?
Direct Answer
Focus testing on a component public interface by asserting rendered output, interacting through simulated user events, and verifying emitted events. Avoid testing internal reactive refs or private methods, because implementation-coupled tests break during refactors without protecting behavior.
Detailed Explanation
### Unit Testing Vue Components by Behavior
Effective Vue component unit testing tests components from the user's and parent component's perspective. It treats the component as a black box: inputs enter via props, slots, and user interactions, while outputs leave via rendered DOM elements and emitted events.
Component Boundary
┌───────────────────────────────┐
Props ──────>│ │──────> Rendered DOM
Slots ──────>│ <UserCard.vue> │──────> Emitted Events
User Action ─>│ │──────> Network / External Calls
└───────────────────────────────┘
▲
│ Avoid asserting
│ internal refs directly!
┌────────────────┴───────────────┐
│ Internal State: count, isOpen │
└────────────────────────────────┘
---
### Key Testing Principles
1. Test Public Contracts, Not Internal Implementation:
status="active" renders an element with text "Active".'update' with the expected payload.wrapper.vm.internalCount directly. Internal variable names should be free to change during refactoring without breaking tests.2. Public Output vs Internal State:
ref into a computed property or extract logic into a composable, implementation-coupled tests fail even though the user-facing behavior remains completely intact.3. Snapshots vs Behavior Tests:
expect(wrapper.html()).toMatchSnapshot()) capture the rendered markup at a single moment in time.jest -u or vitest -u) when markup changes, missing subtle regressions. Snapshots do not test user interaction, keyboard accessibility, or event emission.4. Shallow Mounting vs Full Mounting:
shallowMount stubs out all child components with dummy stub components.mount with real children for small-to-medium component trees, only stubbing expensive boundary dependencies like HTTP clients or browser APIs.---
### Practical Vue Test Utils Example
`ts
// UserCounter.vue
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
initialCount?: number
max?: number
}>()
const emit = defineEmits<{
(e: 'change', count: number): void
}>()
const count = ref(props.initialCount ?? 0)
function increment() {
if (props.max !== undefined && count.value >= props.max) return
count.value++
emit('change', count.value)
}
</script>
<template>
<div class="counter">
<span data-testid="count-display">Current count: {{ count }}</span>
<button
data-testid="increment-btn"
:disabled="props.max !== undefined && count >= props.max"
@click="increment"
>
Increment
</button>
</div>
</template>
`ts
// UserCounter.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import UserCounter from './UserCounter.vue'
describe('UserCounter.vue', () => {
it('renders initial count from props', () => {
const wrapper = mount(UserCounter, {
props: { initialCount: 5 }
})
expect(wrapper.find('[data-testid="count-display"]').text()).toBe('Current count: 5')
})
it('increments count and emits change event on button click', async () => {
const wrapper = mount(UserCounter, {
props: { initialCount: 0 }
})
// Simulate real user interaction
await wrapper.find('[data-testid="increment-btn"]').trigger('click')
// Verify public DOM output
expect(wrapper.find('[data-testid="count-display"]').text()).toBe('Current count: 1')
// Verify emitted event contract
const emitted = wrapper.emitted('change')
expect(emitted).toBeTruthy()
expect(emitted![0]).toEqual([1])
})
it('disables increment button when max is reached', async () => {
const wrapper = mount(UserCounter, {
props: { initialCount: 2, max: 2 }
})
const button = wrapper.find('[data-testid="increment-btn"]')
expect(button.attributes('disabled')).toBeDefined()
await button.trigger('click')
expect(wrapper.emitted('change')).toBeUndefined()
})
})
---
### Core Distinctions to Preserve
Code Example
import { mount } from '@vue/test-utils'
import UserProfile from './UserProfile.vue'
// Good: Test user-visible outcome and public contract
test('emits save event with updated name on form submit', async () => {
const wrapper = mount(UserProfile, {
props: { initialName: 'Alice' }
})
await wrapper.find('input[name="username"]').setValue('Bob')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.emitted('save')).toEqual([[{ name: 'Bob' }]])
})
// Bad: Inspecting private internal component state directly
test('internal state updates directly', () => {
const wrapper = mount(UserProfile)
expect((wrapper.vm as any).internalDraftName).toBe('Alice') // Fragile!
})STAR Method Blueprint
Common Interview Pitfalls
- Asserting wrapper.vm internal state rather than querying rendered DOM elements or emitted events
- Relying solely on HTML snapshots that get blindly accepted during team code reviews
- Stubbing every child component with shallowMount, missing critical integration failures between parent and child
- Forgetting to await user event triggers before asserting DOM updates
How does TypeScript improve Vue 3 component safety for props, emits, refs, and component APIs, and what safety guarantees does it not provide?
Direct Answer
TypeScript provides compile-time contract checking for props, emits, and reactive refs using type-based macros like defineProps and defineEmits. It enforces internal developer usage but provides zero runtime validation for untrusted external inputs such as API responses or user form payloads.
Detailed Explanation
### TypeScript Integration in Vue 3 Composition API
Vue 3 was written from the ground up in TypeScript, offering first-class type safety for Single-File Components. In <script setup>, compiler macros provide ergonomic compile-time typing for props, emitted events, and template refs without runtime overhead.
---
### Core Type-Safety Patterns in <script setup>
#### 1. Type-Based defineProps
Instead of runtime argument objects, defineProps accepts a generic type argument:
`vue
<script setup lang="ts">
interface User {
id: string
name: string
role: 'admin' | 'editor' | 'viewer'
}
// Type declaration: validated at compile time by TypeScript
interface Props {
user: User
isActive?: boolean
tags?: string[]
}
// withDefaults provides fallback values for optional props
const props = withDefaults(defineProps<Props>(), {
isActive: false,
tags: () => []
})
</script>
#### 2. Type-Based defineEmits
Emits can be typed using call signature syntax or tuple syntax:
`vue
<script setup lang="ts">
// Named event arguments with specific payload types
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'select', id: string, meta: { timestamp: number }): void
(e: 'close'): void
}>()
// Alternative tuple syntax (Vue 3.3+):
// const emit = defineEmits<{
// 'update:modelValue': [value: string]
// select: [id: string, meta: { timestamp: number }]
// close: []
// }>()
function handleSelect(id: string) {
emit('select', id, { timestamp: Date.now() })
}
</script>
#### 3. Typing Reactive State and Refs
Refs infer primitive types automatically, but complex or nullable state requires explicit generic parameterization:
`ts
import { ref, computed } from 'vue'
// Explicit union type for async/nullable state
const currentUser = ref<User | null>(null)
// Inferred as ComputedRef<boolean>
const isAdmin = computed(() => currentUser.value?.role === 'admin')
---
### What TypeScript Guarantees vs What It Does Not
| TypeScript Capability | What It Guarantees | What It Does NOT Guarantee |
| :--- | :--- | :--- |
| Compile-time Props | Catches typos and incorrect prop types in parent templates | Does not prevent invalid data coming from an external JSON API at runtime |
| Typed Emits | Ensures components emit specified event names and payload shapes | Does not enforce listener presence or runtime validation of emitted values |
| Type Assertions (`as`) | Informs the compiler to treat a value as a specific type | Zero runtime validation; if backend omits a field, runtime crashes still occur |
| Non-null Assertion (`!`)| Silences compiler warnings about null or undefined | Does not prevent Cannot read properties of null at runtime if data is missing |
---
### External Boundaries: Compile-Time vs Runtime Safety
A common production mistake is assuming that typing a prop or ref makes external data safe:
`ts
// Dangerous: Type assertion provides zero runtime safety
const response = await fetch('/api/user')
const user = (await response.json()) as User // TypeScript believes this is User!
// If backend returns { error: "Not found" }, user.name will crash at runtime!
To achieve true end-to-end safety, untrusted external inputs (API payloads, user inputs, query parameters) must be validated with runtime schema parsers (like Zod or Valibot) before being assigned to typed Vue state.
---
### Core Distinctions to Preserve
as User merely silences compiler errors; it does not transform or validate the underlying object.user!.name tells TypeScript to trust you, but JavaScript will still throw an error if user is null.Code Example
<script setup lang="ts">
import { ref } from 'vue'
interface Product {
id: string
title: string
price: number
}
// 1. Compile-time prop contract with default values
const props = withDefaults(
defineProps<{
product: Product
discountPercent?: number
}>(),
{
discountPercent: 0
}
)
// 2. Strict emit signatures
const emit = defineEmits<{
(e: 'purchase', productId: string, finalPrice: number): void
}>()
// 3. Nullable reactive state
const pendingPaymentId = ref<string | null>(null)
function checkout() {
const finalPrice = props.product.price * (1 - props.discountPercent / 100)
emit('purchase', props.product.id, finalPrice)
}
</script>STAR Method Blueprint
Common Interview Pitfalls
- Assuming that TypeScript type assertions (e.g. data as User) perform runtime validation on API responses
- Overusing the non-null assertion operator (!) to bypass null checks on asynchronous state
- Mixing runtime Options API prop declarations with type-only defineProps macros in the same component
- Falling back to any when typing complex nested props or emitted events
How should a Vue developer diagnose and optimize component rendering performance, computed derivations, watchers, and large lists?
Direct Answer
Profile first using browser and Vue Devtools before optimizing. Optimize by avoiding deep watchers over large objects, keeping computed getters pure, virtualizing huge lists to limit DOM nodes, and using stable unique keys instead of array indices so Vue reuses elements correctly.
Detailed Explanation
### Diagnosing and Optimizing Vue 3 Component Performance
Performance optimization in Vue 3 must always begin with measurement. Premature optimization introduces code complexity without measurable benefits. Understanding where CPU cycles and memory are spent requires separating reactive dependency tracking from browser DOM rendering.
---
### Diagnostic Workflow: Profile Before You Optimize
1. Browser Performance Panel:
2. Vue Devtools Performance Timeline:
---
### Common Bottlenecks and Target Solutions
#### 1. Computed Caching vs Expensive Recomputations
computed() getters only rerun when their reactive dependencies change.#### 2. Deep Watchers on Large Reactive Trees
watch(source, callback, { deep: true }) recursively traverses every nested property in the object graph to register dependency tracking.() => store.filterCriteria) instead of entire objects.#### 3. Large Lists and DOM Bloat
v-for creates massive memory overhead, slow initial mounts, and sluggish scroll performance.#### 4. Stable Keys vs Array Indices
:key="index" breaks Vue's virtual DOM diffing algorithm when items are sorted, filtered, or inserted.:key="item.id").#### 5. shallowRef for Large Immutable Datasets
ref() wraps every nested object in a reactive Proxy.shallowRef(). Only mutations to .value itself are tracked, saving substantial memory and initialization CPU.`ts
import { shallowRef } from 'vue'
// Only .value reassignment triggers reactivity, not items.value[0].title = 'New'
const dataset = shallowRef<LargeRecord[]>([])
function updateData(newData: LargeRecord[]) {
dataset.value = newData // Fast! Zero proxy wrapping of 10,000 nested items.
}
#### 6. Subtree Memoization with v-memo (Vue 3.2+)
v-memo="[item.id, item.selected]" skips VNode creation and diffing for a DOM sub-tree unless the specified dependency array changes.---
### Core Distinctions to Preserve
Code Example
<script setup lang="ts">
import { shallowRef, computed } from 'vue'
interface MetricRow {
id: string
label: string
value: number
isHighlighted: boolean
}
// 1. Use shallowRef for large data structures to bypass deep Proxy wrapping
const allRows = shallowRef<MetricRow[]>([])
// 2. Keep computed derivation pure and focused on minimal dependencies
const highlightedCount = computed(() => {
return allRows.value.filter(r => r.isHighlighted).length
})
</script>
<template>
<div class="metrics-container">
<p>Highlighted: {{ highlightedCount }}</p>
<!-- 3. Use v-memo to skip re-rendering unchanged list items -->
<div
v-for="row in allRows"
:key="row.id"
v-memo="[row.value, row.isHighlighted]"
class="metric-row"
:class="{ active: row.isHighlighted }"
>
<span>{{ row.label }}</span>
<span>{{ row.value }}</span>
</div>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Applying deep watchers over large Pinia stores or collections without profiling
- Using array index as :key in lists that can be filtered, sorted, or mutated
- Attempting to render thousands of complex DOM nodes simultaneously instead of virtualizing the list
- Wrapping large immutable tabular data in standard ref instead of shallowRef
How should a Vue application test asynchronous workflows, router navigation, Pinia stores, and API interactions across test boundaries?
Direct Answer
Use nextTick to await batched Vue DOM updates, and await promises or flush helper utilities for async API resolutions. Test Pinia stores with real pinia instances or createTestingPinia, use memory routers for route flows, and mock transport boundaries without over-mocking internal behavior.
Detailed Explanation
### Testing Async Workflows, Routing, and State in Vue 3
Testing modern Vue applications requires coordinating multiple asynchronous layers: Vue's internal batched DOM update queue, network promises, client-side routing transitions, and Pinia store mutations. Choosing the right boundaries ensures tests remain fast without sacrificing integration confidence.
---
### Async Coordination: nextTick vs Promise Flushing
User Interaction / State Mutation
│
▼
┌──────────────────────────────┐
│ Microtask / Network Promises │ ───> Await mocked API promises / flushPromises()
└──────────────────────────────┘
│
▼
┌──────────────────────────────┐
│ Vue Batched DOM Update Queue │ ───> Await nextTick()
└──────────────────────────────┘
│
▼
Updated DOM / Emitted Events
1. `nextTick()`:
await nextTick() only waits for DOM reconciliation. It does not wait for unresolved network fetches or queued setTimeout calls.2. `flushPromises()` (from `@vue/test-utils`):
---
### Testing with Vue Router
Avoid heavy mocks like vi.mock('vue-router') when testing navigation flows. Mocking the router prevents testing genuine guard logic, redirect behavior, and parameter updates.
Best Practice: Use a real in-memory router with createMemoryHistory():
`ts
import { createRouter, createMemoryHistory } from 'vue-router'
import { mount } from '@vue/test-utils'
const routes = [
{ path: '/', component: HomePage },
{ path: '/customers/:id', component: CustomerDetailPage }
]
const router = createRouter({
history: createMemoryHistory(),
routes
})
test('navigates to customer detail on button click', async () => {
router.push('/')
await router.isReady()
const wrapper = mount(AppNav, {
global: { plugins: [router] }
})
await wrapper.find('[data-testid="customer-link"]').trigger('click')
await router.isReady()
expect(router.currentRoute.value.path).toBe('/customers/42')
})
---
### Testing with Pinia Stores
1. Unit Testing a Pinia Store:
`ts
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, it, expect } from 'vitest'
import { useCustomerStore } from './customerStore'
beforeEach(() => {
setActivePinia(createPinia()) // Reset state between every test
})
it('updates customer name via action', async () => {
const store = useCustomerStore()
await store.fetchCustomer('1')
expect(store.customer?.name).toBe('Acme Corp')
})
2. Component Integration with `createTestingPinia`:
@pinia/testing provides createTestingPinia() to configure initial state while stubbing actions or letting them execute.stubActions: false allows actions to run against mocked API endpoints.---
### Testing Boundary Discipline
| Test Level | What Is Real | What Is Mocked / Stubbed |
| :--- | :--- | :--- |
| Unit Test | Pure function, store actions, isolated composable | All external HTTP endpoints, timers |
| Component Test | Component template, DOM events, local reactivity | HTTP client, deep third-party libraries |
| Integration Test| Component + Router + Real Pinia Store | External network transport (mocked at API client boundary) |
| End-to-End Test | Full browser, real bundle, real routing, backend or MSW | External 3rd party providers (Stripe, OAuth) |
---
### Core Distinctions to Preserve
nextTick flushes Vue's rendering queue; network fetches require awaiting the promise or using flushPromises.useRouter with a dummy object tests that a function was called, but cannot test guards or param parsing.state.user does not prove the template renders the user's name properly.Code Example
import { mount, flushPromises } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import CustomerCard from './CustomerCard.vue'
import { vi, test, expect } from 'vitest'
import * as customerApi from '@/api/customer'
test('loads customer details and updates DOM', async () => {
// Mock external transport boundary only
vi.spyOn(customerApi, 'fetchCustomer').mockResolvedValue({
id: 'c-101',
name: 'Globex Corp'
})
const wrapper = mount(CustomerCard, {
props: { customerId: 'c-101' },
global: {
plugins: [
createTestingPinia({
initialState: {
customer: { activeId: 'c-101' }
},
stubActions: false // Allow store actions to run
})
]
}
})
// Flush pending asynchronous API promises
await flushPromises()
// Verify visible DOM result
expect(wrapper.find('[data-testid="customer-name"]').text()).toBe('Globex Corp')
})STAR Method Blueprint
Common Interview Pitfalls
- Using await nextTick() to wait for asynchronous network requests instead of flushPromises()
- Sharing a global Pinia or Router instance across multiple tests without resetting state in beforeEach
- Over-mocking both router and stores in component tests, turning tests into tautological assertions of mock calls
- Forgetting to await router.isReady() when initializing memory routers in tests
What accessibility practices should a Vue developer implement when building interactive components, forms, and dynamic content?
Direct Answer
Prioritize native semantic HTML elements over custom divs, explicitly associate form inputs with labels and error messages using aria-describedby, and ensure all interactive controls have visible focus and keyboard support. Use ARIA sparingly to augment rather than replace native semantics.
Detailed Explanation
### Accessibility (a11y) in Vue 3 Applications
Accessibility is a core engineering requirement for production web applications. Vue applications frequently suffer accessibility regressions when developers replace native HTML controls with styled <div> elements or fail to associate dynamic state (such as asynchronous validation errors) with form controls.
---
### Core Principles of Accessible Vue Engineering
#### 1. Semantic HTML First: The First Rule of ARIA
<button>, <dialog>, <select>, or <details>) that provides the required behavior, use it instead of recreating it with generic elements and ARIA attributes.<div @click="submit" class="button"> requires manually adding role="button", tabindex="0", keyboard event listeners (Enter and Space), and managing disabled states. Native <button> gives you all of this for free.#### 2. Form Input and Label Associations
<label> using unique IDs:`vue
<template>
<div class="form-group">
<label :for="inputId">{{ label }}</label>
<input
:id="inputId"
v-model="model"
:aria-invalid="hasError ? 'true' : undefined"
:aria-describedby="hasError ? errorId : hintId"
/>
<p :id="hintId" class="hint">{{ hint }}</p>
<p v-if="hasError" :id="errorId" class="error-msg" role="alert">
{{ errorMessage }}
</p>
</div>
</template>
#### 3. Error Association and Live Announcements
#### 4. Keyboard Navigation and Focus Management
Tab, Shift+Tab, Enter, Space, Escape, arrow keys).outline: none) without providing an equally prominent, high-contrast custom focus indicator.Escape.---
### Accessible Vue Component Design
`vue
<!-- AccessibleDropdown.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
options: string[]
modelValue: string
label: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const isOpen = ref(false)
const dropdownId = 'dropdown-' + Math.random().toString(36).slice(2, 9)
function toggle() {
isOpen.value = !isOpen.value
}
function select(option: string) {
emit('update:modelValue', option)
isOpen.value = false
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen.value) {
isOpen.value = false
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
</script>
<template>
<div class="dropdown-wrapper">
<label :id="dropdownId + '-label'">{{ label }}</label>
<button
type="button"
:aria-haspopup="'listbox'"
:aria-expanded="isOpen"
:aria-labelledby="dropdownId + '-label'"
class="dropdown-trigger"
@click="toggle"
>
{{ modelValue || 'Select an option' }}
</button>
<ul
v-if="isOpen"
role="listbox"
:aria-labelledby="dropdownId + '-label'"
class="dropdown-menu"
>
<li
v-for="option in options"
:key="option"
role="option"
:aria-selected="option === modelValue"
tabindex="0"
@click="select(option)"
@keydown.enter.prevent="select(option)"
@keydown.space.prevent="select(option)"
>
{{ option }}
</li>
</ul>
</div>
</template>
---
### Core Distinctions to Preserve
@click to a non-interactive element does not make it keyboard-focusable or triggerable via Enter or Space.<label> elements.:focus styles without replacement leaves keyboard users blind to their active position on the page.Code Example
<template>
<!-- Good: Semantic form control with full accessibility associations -->
<div class="field-container">
<label :for="emailInputId">Corporate Email</label>
<input
:id="emailInputId"
v-model="email"
type="email"
:aria-invalid="Boolean(emailError)"
:aria-describedby="emailError ? emailErrorId : undefined"
required
/>
<span
v-if="emailError"
:id="emailErrorId"
class="error-text"
role="alert"
>
{{ emailError }}
</span>
</div>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Replacing native buttons with div elements that lack tabindex, keydown handlers, and button roles
- Relying on placeholder text instead of proper label elements with matching for/id attributes
- Removing CSS focus outlines (outline: none) without providing an accessible high-contrast alternative
- Failing to associate validation error messages with input fields via aria-describedby and aria-invalid
In a Vue 3 analytics dashboard with 8,000 table rows, deep store watchers, and custom dropdowns, typing into filter inputs causes severe UI lag, route transitions lose filter state despite passing tests, and keyboard accessibility is broken. How do you diagnose and resolve these cascading quality failures?
Direct Answer
Profile first to separate reactivity overhead from DOM rendering cost. Virtualize the 8,000-row table, remove full-store serialization watchers, and debounce filter inputs. Replace unsafe type casts with boundary guards, replace heavy mocks with router integration, and fix keyboard controls.
Detailed Explanation
### Senior Engineering Incident Post-Mortem & Architecture Recovery
This incident represents a classic cascading failure in a scaling Single-Page Application: performance degradation from unmeasured reactivity and DOM overhead, false confidence from heavily mocked tests, compile-time type safety undermined by unsafe assertions, and complete accessibility regressions in custom UI widgets.
Typing keystroke
│
▼
┌────────────────────────────────────────────────────────┐
│ High-Frequency Filter Input │
└────────────────────────────────────────────────────────┘
│ (triggers every keystroke)
├───> Deep Watcher: JSON.stringify(store.$state) [MASSIVE CPU SPIKE]
├───> Computed: Re-filters & re-sorts 8,000 rows [RECOMPUTATION COST]
└───> Virtual DOM: Re-diffs 8,000 DOM elements [UNVIRTUALIZED DOM BLOAT]
(Unstable index keys cause element thrash!)
---
### Step 1: Diagnose and Measure Before Optimizing
Do not guess whether Vue reactivity or DOM rendering is slow. Profile the interaction using the Chrome DevTools Performance panel and Vue Devtools:
1. CPU Profile: Reveals execution time spent in JSON.stringify serialization and array sorting algorithms on the main thread during keystrokes.
2. Layout & Paint Profile: Shows massive style recalculations and forced reflows across 8,000 table rows (tens of thousands of DOM nodes).
3. Reactivity Timeline: Identifies deep store watchers firing on every keystroke, causing unrelated components to re-evaluate.
---
### Step 2: Separate Reactivity Overhead from DOM Rendering
#### 1. Eliminate Store Serialization in Watchers
watch(() => JSON.stringify(store.$state), ...) serializes the entire application state on every keystroke to detect changes. This allocates megabytes of garbage-collected strings and pins the CPU. `ts
// Good: Granular observation of the specific search filter
watch(() => dashboardStore.filters.searchQuery, (query) => {
// React to filter change
})
#### 2. Virtualize Large Table Rendering
vue-virtual-scroller or custom viewport windowing). Only the 30–50 rows visible in the viewport are rendered in the DOM, keeping DOM nodes constant regardless of dataset size.#### 3. Replace Array Index Keys with Stable Domain IDs
<tr v-for="(row, index) in filteredRows" :key="index"> causes Vue to reuse DOM nodes by position when sorting or filtering, creating rendering glitches and expensive element mutations.:key="row.id".#### 4. Debounce Filter Input Calculations
---
### Step 3: Repair TypeScript Safety at Runtime Boundaries
const data = (await api.getDashboard()) as DashboardData. When backend payload fields changed or were omitted, runtime errors crashed the client despite tsc --noEmit passing cleanly.1. Reject disabling strictness. Strict TypeScript catches invalid assumptions across the codebase.
2. Implement runtime boundary validation using lightweight schema validators (like Zod or Valibot) or explicit type guard functions:
`ts
import { z } from 'zod'
const DashboardDataSchema = z.object({
records: z.array(z.object({ id: z.string(), value: z.number() })),
totalCount: z.number()
})
export async function fetchDashboardSafe(): Promise<DashboardData> {
const raw = await api.get('/dashboard')
const result = DashboardDataSchema.safeParse(raw)
if (!result.success) {
throw new NormalizedError('SCHEMA_MISMATCH', result.error.message)
}
return result.data
}
3. Replace as SomeType assertions and non-null assertions (!) with type narrowing (if (val != null)).
---
### Step 4: Fix the Testing Strategy and Route State Loss
/dashboard?filter=active -> /dashboard?filter=archived), the component was reused without watching route.query, or the store was reset unexpectedly during route transitions.createMemoryHistory) and an active Pinia store.---
### Step 5: Resolve Accessibility (a11y) Violations
1. Custom Dropdown Keyboard Navigation:
<div> elements lacked keyboard handlers.<select> if simple, or augment the custom dropdown with:role="combobox" or role="listbox"tabindex="0" on interactive elementsEnter, Space, Escape, and arrow navigation:focus-visible)2. Form Label and Error Associations:
<label> elements.aria-invalid="true" and aria-describedby="error-id".---
### Root Cause Separation Matrix
| Symptom | Root Cause | Architectural Solution |
| :--- | :--- | :--- |
| Typing Lag & CPU Spikes | Deep watcher calling JSON.stringify on full store; unvirtualized 8,000 DOM rows | Remove state serialization; watch scalar properties; virtualize table to ~40 visible rows |
| Element Diffing Glitches | Using array index as :key in sorted/filtered list | Use stable domain identifier (:key="row.id") |
| Runtime Crashes in Production | Unsafe as Type assertions masking API changes without runtime validation | Add boundary schema validation (Zod/Valibot); reject disabling TypeScript strictness |
| Route State Lost in Production | Tests over-mocked router; component failed to react to route.query updates | Replace mock-heavy unit tests with in-memory router integration tests; synchronize query state |
| Inaccessible Dropdown & Forms | Generic <div> controls without keyboard handlers or ARIA associations | Implement full keyboard navigation (Tab/Enter/Escape), visible focus, and aria-describedby |
Code Example
// 1. Safe boundary validation replacing unsafe assertions
import { z } from 'zod'
import { shallowRef, computed } from 'vue'
const RowSchema = z.object({
id: z.string(),
name: z.string(),
score: z.number()
})
export function useDashboardData() {
// 2. Use shallowRef for large immutable datasets to eliminate Proxy overhead
const rows = shallowRef<z.infer<typeof RowSchema>[]>([])
const searchQuery = ref('')
// 3. Computed derivation with stable sorting
const filteredRows = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter(r => r.name.toLowerCase().includes(q))
})
return { rows, searchQuery, filteredRows }
}STAR Method Blueprint
Common Interview Pitfalls
- Assuming Vue reactivity is broken instead of measuring DOM layout overhead and deep store watchers
- Disabling TypeScript strict mode to make compile errors go away instead of fixing type boundaries
- Over-mocking routing and store dependencies, resulting in 100% green tests that miss real integration bugs
- Adding ARIA attributes to broken div elements without implementing keyboard listeners or focus management
What are Single-Page Application (SPA), Server-Side Rendering (SSR), and Static Site Generation (SSG) in Vue, and what architectural trade-offs should a developer consider?
Direct Answer
SPAs render in the browser, offering rich interactivity but slower initial loads. SSR renders HTML on the server per request for fast initial paint and SEO, while SSG pre-renders pages at build time. SSR requires Node server infrastructure and hydration, whereas SSG fits static content.
Detailed Explanation
### Rendering Paradigms in Modern Vue Architecture
Choosing between Single-Page Application (SPA), Server-Side Rendering (SSR), and Static Site Generation (SSG) determines your application's initial load performance, search engine indexing, hosting costs, and operational complexity.
1. SPA (Client-Side Rendering)
Client Browser: Requests URL ──> Receives Empty HTML + JS Bundle ──> Executes JS & Mounts DOM
[Fast subsequent navigation | Slower initial content display (TTFB to FCP lag)]
2. SSR (Server-Side Rendering)
Client Browser: Requests URL ──> Node Server Renders Vue App ──> Receives Full HTML ──> Hydrates JS
[Immediate content visibility & SEO | Requires server runtime & hydration overhead]
3. SSG (Static Site Generation / Pre-rendering)
Build Step: Pre-renders all routes to static HTML files ──> Deployed to CDN ──> Client Hydrates JS
[Fastest possible TTFB from edge CDN | Must rebuild pages when content changes]
---
### Comparison of Architectural Models
| Dimension | SPA (Client-Side) | SSR (Server-Side) | SSG (Static Pre-rendering) |
| :--- | :--- | :--- | :--- |
| Initial Content (FCP)| Slow (requires downloading and running JS) | Fast (server delivers fully rendered HTML) | Fastest (static HTML delivered directly from CDN) |
| SEO & Crawlers | Relies on search engines executing client JS | Excellent (crawlers see fully formed HTML) | Excellent (static HTML immediately crawlable) |
| Server Costs | Lowest (hosted entirely on static object storage/CDN) | Highest (requires running Node.js servers per request)| Low (cached on global CDN edge nodes) |
| Dynamic Content | Highly dynamic, fetched via client API calls | Generated per request using incoming cookies/headers | Static at build time; requires client-side fetching |
| Operational Overhead| Minimal (no server infrastructure to scale or monitor) | High (requires cluster scaling, monitoring, restarts) | Moderate (requires build pipelines or revalidation) |
---
### Key Architectural Trade-offs
1. Hydration Overhead:
2. Server Execution Safety:
window, document, localStorage) do not exist. Accessing browser APIs during component setup crashes the server process.3. Caching and Scalability:
---
### Core Distinctions to Preserve
Code Example
// Entry architecture setup demonstrating SSR vs Client-only app creation
// 1. Client-only SPA entry (main.ts)
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
// 2. Universal SSR entry factory (entry-server.ts / entry-client.ts)
// In SSR, factory functions create fresh instances per request to prevent cross-request leaks
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
export function createAppInstance() {
const app = createSSRApp(App)
const pinia = createPinia()
const router = createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes: []
})
app.use(pinia)
app.use(router)
return { app, pinia, router }
}STAR Method Blueprint
Common Interview Pitfalls
- Assuming that adopting SSR eliminates the need for client-side JavaScript bundles
- Accessing browser globals like window or localStorage directly in component setup functions on the server
- Using SSG for highly dynamic user accounts that require per-request personalization
- Failing to configure separate cache control headers for public vs personalized SSR responses
How should a Vue developer use dynamic imports, route-level code splitting, and async components to optimize bundle loading and delivery boundaries?
Direct Answer
Route-level code splitting with dynamic imports splits the application into on-demand JavaScript chunks. Async components lazy-load heavy widgets only when rendered. Splitting reduces initial bundle weight, but over-splitting can cause network waterfalls and layout shift if uncoordinated.
Detailed Explanation
### Code Splitting and Lazy Loading in Vue 3 Applications
Modern web applications grow in complexity, increasing the total JavaScript bundle size. If an entire application is packaged into a single monolithic bundle, initial page load suffers significantly. Vue leverages ES dynamic imports (import()) and bundler chunking (via Vite or Rollup) to partition code into demand-driven packages.
---
### Core Lazy Loading Mechanisms
#### 1. Route-Level Code Splitting
The most impactful code splitting boundary in a Vue application is at the route definition:
`ts
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue') // Chunk loaded only when visiting '/'
},
{
path: '/analytics',
name: 'analytics',
// Heavy charting libraries will only load if user navigates to /analytics
component: () => import('@/views/AnalyticsDashboard.vue')
}
]
})
#### 2. Component-Level Lazy Loading with defineAsyncComponent
When a heavy component lives inside an otherwise lightweight page (e.g. an interactive rich text editor or a PDF viewer modal), defineAsyncComponent defers downloading the component bundle until it is actually rendered:
`vue
<script setup lang="ts">
import { ref, defineAsyncComponent } from 'vue'
const showEditor = ref(false)
// The RichTextEditor chunk is only fetched over the network when showEditor becomes true
const RichTextEditor = defineAsyncComponent({
loader: () => import('@/components/RichTextEditor.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // Show loading component only if loading takes longer than 200ms
timeout: 10000 // Error out if download takes longer than 10s
})
</script>
<template>
<div>
<button @click="showEditor = true">Open Editor</button>
<RichTextEditor v-if="showEditor" />
</div>
</template>
---
### Architectural Trade-offs and Best Practices
1. Network Waterfalls vs Bundle Size:
2. Loading States and Layout Shift (CLS):
3. Prefetching and Preloading:
/* webpackPrefetch: true */ or <link rel="prefetch">). When the browser is idle, it opportunistically pre-downloads chunks for links currently visible in the viewport.---
### Core Distinctions to Preserve
Code Example
// Managing async components with explicit loading and error fallbacks
import { defineAsyncComponent, h } from 'vue'
export const AsyncBillingModal = defineAsyncComponent({
loader: () => import('@/features/billing/ui/BillingModal.vue'),
loadingComponent: {
render() {
return h('div', { class: 'skeleton-modal' }, 'Loading billing details...')
}
},
errorComponent: {
render() {
return h('div', { class: 'error-banner' }, 'Failed to load billing module. Please retry.')
}
},
delay: 150,
timeout: 8000
})STAR Method Blueprint
Common Interview Pitfalls
- Over-splitting small UI components into hundreds of tiny chunks that degrade mobile network performance
- Failing to handle async component loading and error states, leading to blank or frozen UI on network drops
- Confusing code bundle chunking with asynchronous data fetching inside component lifecycle hooks
- Neglecting layout reservation for async components, causing severe Cumulative Layout Shift (CLS)
What is client-side hydration in a server-rendered Vue application, and what architectural discrepancies cause hydration mismatch errors?
Direct Answer
Hydration attaches Vue reactive listeners and state to server-rendered HTML. A hydration mismatch occurs when initial client rendering conflicts with server HTML. Common causes include nondeterministic Date.now calls, browser APIs during setup, and divergent locale or timezone formatting.
Detailed Explanation
### Understanding Client-Side Hydration in Vue 3 SSR
In a server-rendered Vue application, the server generates static HTML and sends it to the browser. The browser paints this HTML immediately, giving the user instant visual feedback. However, this initial HTML is completely static—event listeners, reactive bindings, and computed dependencies have not yet been attached.
Hydration is the process where Vue runs on the client, traverses the existing DOM nodes generated by the server, and attaches internal reactive listeners and VNode structures without re-creating the DOM from scratch.
1. Server Render:
Vue Virtual DOM ──> renderToString() ──> Static HTML Output: <button class="btn">Count: 0</button>
2. Client Browser Receives HTML:
Browser parses markup and displays button immediately (non-interactive).
3. Client Hydration (createSSRApp):
Vue generates client Virtual DOM ──> Compares against existing DOM ──> Attaches @click event listener!
[If Client VNode != Server DOM ──> HYDRATION MISMATCH ERROR!]
---
### What Causes Hydration Mismatch Errors?
A hydration mismatch occurs when the Virtual DOM tree generated during the client's initial render pass does not match the DOM hierarchy delivered in the server's HTML. Vue expects the initial client render to be 100% deterministic and identical to the server render.
#### Common Culprits:
1. Non-Deterministic Dynamic Values:
Date.now(), new Date().toLocaleTimeString(), or Math.random() directly in the template.2. Browser-Only APIs in Setup:
window.innerWidth, document.cookie, or localStorage during component setup.window is undefined (or polyfilled), rendering fallback markup; on the client, window.innerWidth evaluates to real dimensions, producing different conditional DOM elements.3. Locale and Timezone Discrepancies:
4. Invalid HTML Nesting:
<p><div>Block content</div></p> or <table><tr>...</tr></table> without a <tbody>.---
### Architecture Patterns for Safe Hydration
#### Pattern 1: Defer Client-Only State to onMounted
Lifecycle hooks like onMounted and onUpdated only execute on the client, never on the server. Code that depends on browser APIs should be deferred to onMounted:
`vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
// Render a stable baseline on both server and initial client render
const windowWidth = ref<number | null>(null)
onMounted(() => {
// Safe: Executes only in the browser after initial hydration completes
windowWidth.value = window.innerWidth
})
</script>
<template>
<div>
<!-- Render skeleton or neutral layout until mounted -->
<p v-if="windowWidth !== null">Viewport width: {{ windowWidth }}px</p>
<p v-else>Loading viewport details...</p>
</div>
</template>
#### Pattern 2: State Serialization
Pass the exact state used during server rendering to the client by serializing it into a global variable (e.g. window.__INITIAL_STATE__). The client initializes its Pinia store or refs from this payload, ensuring identical data inputs during initial render.
---
### Core Distinctions to Preserve
window or document during server setup will terminate the Node.js rendering process.Code Example
// Safe date rendering avoiding hydration discrepancies
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{
// Timestamp generated on server and passed via props
publishedTimestamp: number
}>()
// Use a deterministic ISO format for initial server and client render
const formattedDate = ref(new Date(props.publishedTimestamp).toISOString().slice(0, 10))
onMounted(() => {
// Update to user's localized relative time only after hydration is complete
formattedDate.value = new Intl.DateTimeFormat(navigator.language, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(props.publishedTimestamp))
})
</script>
<template>
<time :datetime="new Date(props.publishedTimestamp).toISOString()">
{{ formattedDate }}
</time>
</template>STAR Method Blueprint
Common Interview Pitfalls
- Rendering Date.now() or Math.random() directly in template markup during SSR
- Reading window or localStorage during script setup instead of deferring to onMounted
- Allowing invalid HTML nesting (like div inside p) that browser parsers mutate before hydration
- Relying on default server locale for currency and date formatting instead of explicit user locale
How should a growing Vue application organize feature modules, state ownership, composables, Pinia stores, and API clients to maintain scalable boundaries?
Direct Answer
Structure by vertical domain features rather than technical file types. Keep UI state local to components, encapsulate reusable stateful workflows in domain composables, use Pinia stores only for genuine cross-feature shared data, and access backend endpoints through dedicated API clients.
Detailed Explanation
### Scaling Large Vue 3 Applications: Modular Architecture & Data Flow
As Vue applications grow from small projects into enterprise codebases with dozens of developers, flat folder structures (e.g. putting all components in src/components and all stores in src/stores) break down. A scalable architecture groups code by domain features, defines strict state ownership, and enforces clean dependency direction.
---
### Vertical Feature Slicing
Rather than organizing by technical file type, organize by business capability:
src/
├── app/ # Application entry, router, global plugins, root layout
├── shared/ # Domain-agnostic UI primitives (Button, Modal), utilities, base HTTP
│ ├── ui/
│ └── api/
└── features/ # Self-contained business domains
├── auth/ # Domain feature: authentication
│ ├── api/ # Auth endpoints and DTOs
│ ├── composables/ # useAuthSession, useLoginForm
│ ├── stores/ # authSessionStore (user identity, tokens)
│ └── ui/ # LoginForm.vue, AuthModal.vue
└── billing/ # Domain feature: subscriptions & invoicing
├── api/
├── composables/ # useSubscriptionTier
└── ui/ # PricingTable.vue, InvoiceList.vue
---
### State Ownership Hierarchy
A common architectural trap is treating Pinia as a dumping ground for all state. Establish strict state ownership boundaries:
┌────────────────────────────────────────────────────────┐
│ 1. Local Component State (ref, reactive in <script>) │
│ - Form inputs, modal open/close, accordion toggles │
└────────────────────────────────────────────────────────┘
▲
│
┌────────────────────────────────────────────────────────┐
│ 2. Feature / Composable State (useFeatureComposable) │
│ - Data fetching, pagination, filter query builder │
└────────────────────────────────────────────────────────┘
▲
│
┌────────────────────────────────────────────────────────┐
│ 3. Global Shared State (Pinia Store) │
│ - Authenticated user, shopping cart, permissions │
└────────────────────────────────────────────────────────┘
▲
│
┌────────────────────────────────────────────────────────┐
│ 4. Server State (Remote Database via API Client) │
│ - Source of truth residing behind REST/GraphQL │
└────────────────────────────────────────────────────────┘
---
### Unidirectional Dependency Flow
To prevent spaghetti dependencies across features:
1. Features must not import private internals from other features:
billing needs user profile data, it should consume the public interface of auth (e.g. useAuth() or authStore), never reach into features/auth/ui/InternalAuthWidget.vue.2. Shared primitives must never depend on features:
shared/ui/AppButton.vue must have zero knowledge of features/billing.3. Transport Separation:
---
### Core Distinctions to Preserve
billing does not create an architectural boundary if other features freely mutate its internal state.utils.ts files often become covert coupling points that hide architectural entanglements.Code Example
// Clean feature boundary: Composable encapsulating feature state and API transport
// features/billing/composables/useSubscription.ts
import { ref, computed } from 'vue'
import { billingApi, type SubscriptionPlan } from '../api/billingApi'
import { useAuthStore } from '@/features/auth/stores/authStore'
export function useSubscription() {
const authStore = useAuthStore() // Consume public interface of auth feature
const plans = ref<SubscriptionPlan[]>([])
const activePlanId = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const currentPlan = computed(() => {
return plans.value.find(p => p.id === activePlanId.value) ?? null
})
async function loadPlans() {
isLoading.value = true
error.value = null
try {
plans.value = await billingApi.getPlans()
activePlanId.value = authStore.user?.planId ?? null
} catch (err: any) {
error.value = err.message || 'Failed to load subscription plans'
} finally {
isLoading.value = false
}
}
return { plans, currentPlan, isLoading, error, loadPlans }
}STAR Method Blueprint
Common Interview Pitfalls
- Dumping all component UI state into global Pinia stores instead of scoping to components or composables
- Creating circular dependencies between Pinia stores or across feature modules
- Organizing by technical file type (all components in one folder) rather than by domain features in large apps
- Letting UI components execute raw HTTP requests directly without service or composable abstraction layers
How should a production Vue application capture runtime errors, observe performance metrics, manage source maps, and execute safe deployments?
Direct Answer
Capture uncaught errors with app.config.errorHandler and onErrorCaptured without exposing sensitive PII. Upload hidden source maps to private APM tools, track Core Web Vitals, and deploy via canary stages or feature flags with automated rollbacks when error rates spike.
Detailed Explanation
### Production Observability, Error Handling, and Release Safety in Vue 3
Deploying a Vue application to production requires more than passing unit tests. A robust production lifecycle establishes real-time client error monitoring, performance telemetry, secure source map management, and risk-mitigated release strategies.
---
### Runtime Error Handling Architecture
Vue 3 provides specialized hooks to intercept errors across the component lifecycle:
Component Error Occurs (render, watcher, lifecycle)
│
▼
┌──────────────────────────────────────────────┐
│ 1. onErrorCaptured(err, instance, info) │ ──> Can swallow or propagate error
└──────────────────────────────────────────────┘
│ (if unhandled or propagated)
▼
┌──────────────────────────────────────────────┐
│ 2. app.config.errorHandler(err, vm, info) │ ──> Global catch-all handler
└──────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ APM Telemetry Dispatch (Sentry, Datadog) │ ──> Sanitizes PII before sending
└──────────────────────────────────────────────┘
1. Global Error Handler (`app.config.errorHandler`):
vm) and lifecycle string (info, e.g. "render function", "mounted hook").2. Error Boundary Components (`onErrorCaptured`):
---
### Source Map Security
build.sourcemap = 'hidden' in Vite)..map files from the public web server or CDN distribution before deploying.---
### Performance Telemetry & Web Vitals
Track user experience metrics in production using the browser's PerformanceObserver API or web-vitals library:
---
### Safe Release Strategies
1. Feature Flags:
2. Canary & Staged Rollouts:
3. Automated Rollback Triggers:
---
### Core Distinctions to Preserve
Code Example
// Global error telemetry configuration with sanitization
import type { App } from 'vue'
export function setupErrorMonitoring(app: App, reportService: any) {
app.config.errorHandler = (err, instance, info) => {
// 1. Extract non-sensitive component context
const componentName = instance?.$options?.name || instance?.$options?.__name || 'AnonymousComponent'
// 2. Sanitize and structure telemetry payload
const errorPayload = {
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
component: componentName,
lifecyclePhase: info,
currentRoute: window.location.pathname,
timestamp: Date.now()
}
// 3. Dispatch to remote observability platform
reportService.captureException(errorPayload)
// 4. Log to console in development
if (import.meta.env.DEV) {
console.error('[Vue Global Error]', err, info)
}
}
}STAR Method Blueprint
Common Interview Pitfalls
- Deploying unhidden source maps to public CDNs, exposing proprietary source code to the public
- Logging raw form inputs or auth tokens in client error telemetry payloads, violating user privacy
- Assuming that a zero-error backend deployment means frontend client users are experiencing zero regressions
- Neglecting to handle component error boundaries, allowing an isolated widget failure to crash the entire application
In a Vue 3 SSR commerce application, users see flash-of-mismatched currency, console hydration errors, Date.now mismatches, and suspected personalized user data leakage across shared CDN caches. How do you contain, diagnose, and permanently re-architect the system?
Direct Answer
Immediately bypass shared CDN caching on personalized routes and purge leaked caches. Eliminate nondeterministic Date.now and window calls from initial render. Instantiate Vue and Pinia per request on the server to prevent cross-request state leakage, and isolate client-only widgets.
Detailed Explanation
### Senior Engineering Incident Post-Mortem & Architecture Recovery
This production incident involves two distinct failure domains with vastly different risk profiles:
1. Hydration Consistency Failures (UX & Performance Bug): DOM mismatches caused by nondeterministic timestamps, browser-only APIs, and locale discrepancies.
2. Personalized SSR Cache & State Leakage (High-Severity Security Incident): Shared CDN caching of authenticated HTML combined with potential module-level singleton state sharing in the Node.js SSR runtime, serving User A's private account data to User B.
Incoming HTTP Request
│
├───> CDN Edge Cache
│ [CRITICAL BUG: Shared CDN cached authenticated /account HTML!]
│ └───> Returns User A's cached account page to User B!
│
▼ (Cache MISS -> Origin Node Server)
Node.js SSR Server Process
[CRITICAL BUG: Module-scoped singleton: const pinia = createPinia()!]
User B's request mutates shared store ──> Leaks state to User C's concurrent request!
│
▼
HTML with Serialized State ──> Sent to Client Browser
│
▼
Client Hydration
[BUG: Date.now() & window.innerWidth differ between Server and Client!]
──> Console Hydration Mismatch Warnings & Layout Flash!
---
### Step 1: Immediate Triage & Security Containment
Suspected cross-user data leakage is a Severity 1 security incident. Immediate action must prevent ongoing exposure before root-cause analysis:
1. Bypass CDN Caching on Authenticated Routes:
Cache-Control: private, no-cache, no-store, must-revalidate.2. Purge Affected CDN Caches:
/account/*, /checkout/*, /cart/*).3. Audit Log Scope:
X-Cache: HIT), request IDs, and session IDs to establish the exact time window and quantify user exposure.---
### Step 2: Separate and Remediate Hydration Mismatches
Hydration mismatches degrade performance and cause UI flicker, but they do not cause cross-user data leakage. Treat them as a separate engineering problem:
#### 1. Eliminate Nondeterministic Timestamps (Date.now())
Date.now() during template execution. The server rendered the build/render time, while the client evaluated the current time ~300ms later during hydration.onMounted().#### 2. Guard Browser-Only Globals (window.innerWidth, localStorage)
window.innerWidth and localStorage during setup throws errors on the server or causes divergent conditional branches during initial client hydration.onMounted(), which runs exclusively in the browser after initial hydration.#### 3. Align Locale and Currency Formatting
en-US), while the client browser formatted according to client OS settings (de-DE), causing $10.00 vs 10,00 € mismatches./en/..., /de/...) or an explicit cookie on the server, serialize the locale into the page state, and use explicit locale arguments in Intl.NumberFormat.---
### Step 3: Eliminate Cross-Request State Leakage in SSR
In Node.js, JavaScript modules are singletons by default. If you create an application, router, or Pinia store at the module root, that instance is shared across all incoming requests:
`ts
// FATAL SSR BUG: Module-scoped singleton store
import { createPinia } from 'pinia'
const pinia = createPinia() // Shared across EVERY user request in the Node process!
export default pinia
#### The Architecture Fix: Factory Functions Per Request
Every incoming HTTP request must instantiate a completely isolated Vue app, Pinia store, and Vue Router instance:
`ts
// server-entry.ts
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import App from './App.vue'
export function createAppForRequest() {
const app = createSSRApp(App)
const pinia = createPinia() // Fresh store per request!
const router = createRouter({
history: createMemoryHistory(),
routes: []
})
app.use(pinia)
app.use(router)
return { app, pinia, router }
}
---
### Step 4: Secure State Serialization
serialize-javascript) to escape <, >, and & characters, preventing cross-site scripting (XSS) via injected script tags.---
### Step 5: Route Classification & Mixed Rendering Architecture
Reject dogmatic proposals like "disable SSR everywhere" or "suppress hydration warnings":
| Route Category | Rendering Strategy | Caching Policy | Rationale |
| :--- | :--- | :--- | :--- |
| Public Catalog (/products, /categories) | SSR or SSG | Public CDN cache (s-maxage=3600, stale-while-revalidate) | SEO critical; fast TTFB; identical across all users |
| Personalized Account (/account, /orders) | Client-rendered SPA or SSR | Private no-store (Cache-Control: private, no-store) | Zero CDN caching; requires strict user data isolation |
| Hybrid Product Page (/product/:id) | SSR Shell + Client Island | Public CDN cache for shell; private API for cart button | Fast public SEO paint; user-specific widgets hydrate client-side |
---
### Root Cause Separation Matrix
| Symptom | Root Cause | Permanent Architectural Solution |
| :--- | :--- | :--- |
| Currency & Timezone Flash | Client OS locale diverged from server default locale | Explicitly pass request-negotiated locale to client; format deterministically |
| Console Hydration Warnings | Nondeterministic Date.now() and window.innerWidth in initial render | Defer browser globals to onMounted; pass server timestamps via state payload |
| Cross-User Account Leak | Shared CDN cached personalized HTML; module-scoped Pinia singleton on server | Set Cache-Control: private, no-store; instantiate app and Pinia per request |
| Initial "Sign In" Flash | Server rendered anonymous shell while client restored auth token from storage | Use server-verified session cookie for initial render or use client-only auth widget |
Code Example
// Clean SSR server request handler isolating app, store, and headers
import { renderToString } from 'vue/server-renderer'
import { createAppForRequest } from './entry-server'
import type { Request, Response } from 'express'
import serialize from 'serialize-javascript'
export async function handleSsrRequest(req: Request, res: Response) {
// 1. Fresh application and Pinia instance per request
const { app, pinia, router } = createAppForRequest()
const isPersonalizedRoute = req.path.startsWith('/account') || req.path.startsWith('/cart')
// 2. Strict cache control based on route sensitivity
if (isPersonalizedRoute) {
res.setHeader('Cache-Control', 'private, no-cache, no-store, must-revalidate')
} else {
res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=600')
}
await router.push(req.url)
await router.isReady()
const appHtml = await renderToString(app)
// 3. Serialize only safe, sanitized state payload
const safeState = serialize(pinia.state.value, { isJSON: true })
res.send(`
<!DOCTYPE html>
<html>
<body>
<div id="app">${appHtml}</div>
<script>window.__INITIAL_STATE__ = ${safeState};</script>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
`)
}STAR Method Blueprint
Common Interview Pitfalls
- Creating module-level singleton Pinia or Router instances in SSR server runtimes, leaking state across concurrent requests
- Configuring shared CDN caching on personalized or authenticated routes containing user-specific HTML
- Suppressing hydration warnings in production instead of resolving underlying client-server render divergence
- Serializing raw server data containing private credentials or API keys directly into public client HTML
Want to tailer your resume for Vue.js Developer roles?
Import your resume, scan it for critical Vue.js Developer keywords, and compare it against ATS standards instantly.
