Vue Idioms and Patterns
Core Philosophy
Vue 3 Composition API is the default for all new code. <script setup> is the canonical syntax. Think in terms of reactive data flows, not component lifecycle hooks. Composables (use* functions) are the primary unit of logic reuse.
Scope: This file covers Vue 3 coding idioms for components, stores, and composables. For TypeScript type system patterns, see @.agents/skills/typescript-idioms/SKILL.md. For file and folder layout, see references/project-structure.md (and the shared @.agents/skills/frontend-design/references/frontend-layout.md). For test naming, see @.agents/rules/testing-strategy.md. For logging, see @.agents/skills/logging-implementation/SKILL.md.
Loading guard: Do NOT load this skill for non-Vue projects. React → react-idioms; Angular → angular-idioms; Next.js → nextjs-idioms. This skill co-loads with typescript-idioms (required for any Vue work).
When to Load References
Always load typescript-idioms first — it is required alongside this skill for any Vue work.
Load these before writing code in the matching context — not after.
| Situation |
Reference to Load |
| TypeScript type system, async, Zod, error types |
@.agents/skills/typescript-idioms/SKILL.md (always co-load) |
| Starting a new Vue project or reviewing file layout |
references/project-structure.md |
| Choosing Vue ecosystem package versions, Vite/Vitest config |
references/recommended-dependencies.md |
| Defining Zod schemas or validating boundaries |
@.agents/skills/typescript-idioms/references/zod-patterns.md |
| Writing code that handles user input, async, or I/O |
@.agents/skills/typescript-idioms/references/ts-patterns-and-anti-patterns.md |
Toolchain and Version Milestones
Default to the latest Vue 3 stable. As of July 2026, Vue 3.5+ with Vite 6+.
Key version milestones that affect this skill:
- 3.5+ —
useTemplateRef (type-safe template refs), improved useId, Suspense stable
- 3.4+ —
defineModel (replaces verbose v-model boilerplate), improved watch generics
- 3.3+ —
defineOptions, defineSlots, generic components with <script setup>
- 3.2+ —
<script setup> syntax finalized
For recommended package versions and starter configs, see references/recommended-dependencies.md.
<script setup> — The Only Style
Always use <script setup lang="ts">. Never use the Options API or the class-style component pattern for new code.
<!-- ✅ Canonical style -->
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = defineProps<{ title: string; count?: number }>();
const emit = defineEmits<{ 'update:count': [value: number] }>();
const doubled = computed(() => (props.count ?? 0) * 2);
</script>
<!-- ❌ Options API — do not use for new components -->
<script lang="ts">
export default { props: { title: String }, ... }
</script>
Reactivity: ref vs reactive
| Use |
When |
ref<T>() |
Primitives, single values, values that may be reassigned |
reactive() |
Plain objects where you always access properties (never reassign the whole object) |
readonly() |
Expose state that must not be mutated outside its owner |
// ✅ ref for primitives and replaceable objects
const count = ref(0);
const user = ref<User | null>(null);
user.value = fetchedUser; // reassignment is fine
// ✅ reactive for objects where you destructure properties
const form = reactive({ title: '', priority: 'medium' });
// ❌ Never destructure a reactive object — reactivity is lost
const { title } = form; // title is now a plain string, NOT reactive
// ✅ Use toRefs if you must destructure
const { title } = toRefs(form);
Computed Properties
Use computed for all derived state — never recompute in the template
// ✅ Cached, reactive
const filteredTasks = computed(() =>
tasks.value.filter(t => t.status === activeFilter.value)
);
// ❌ Recomputes on every render
// <template>{{ tasks.filter(t => t.status === filter) }}</template>
Never cause side effects inside computed — computed must be pure
// ❌ Side effect in computed
const count = computed(() => {
taskStore.logAccess(); // NO — this is a side effect
return tasks.value.length;
});
Use writable computed for two-way bindings
const modelValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
Watch Strategy
Use the most precise watcher for the situation — over-watching is a performance and correctness problem.
| Watcher |
Use When |
watchEffect |
Side effect that should re-run whenever any of its reactive dependencies change; auto-tracks dependencies |
watch |
You need the old value, lazy execution, or want to watch a specific source explicitly |
computed |
You need a synchronous derived value (prefer this over watch for transformation) |
// ✅ watchEffect — auto-tracks dependencies
watchEffect(() => {
document.title = `Tasks (${count.value})`;
});
// ✅ watch — explicit source, has old value
watch(userId, async (newId, oldId) => {
if (newId !== oldId) await loadUser(newId);
}, { immediate: true });
// ❌ Avoid using watch just for computed values
watch(tasks, () => { filteredCount.value = tasks.value.filter(...).length; });
// ✅ Use computed instead
const filteredCount = computed(() => tasks.value.filter(...).length);
Pinia Stores
The store directory structure is defined in references/project-structure.md. This section covers Pinia coding idioms.
Use the Setup Store API (not Options API) for new stores
// task/store/task.store.ts
export const useTaskStore = defineStore('task', () => {
// State
const tasks = ref<Task[]>([]);
const isLoading = ref(false);
// Getters (computed)
const completedTasks = computed(() =>
tasks.value.filter(t => t.status === 'done')
);
// Actions
async function loadTasks() {
isLoading.value = true;
try {
tasks.value = await taskAPI.getTasks();
} finally {
isLoading.value = false;
}
}
return { tasks, isLoading, completedTasks, loadTasks };
});
Never mutate store state from outside the store
// ❌ Direct mutation from a component
const store = useTaskStore();
store.tasks.push(newTask); // NO
// ✅ Call an action
await store.addTask(newTask);
Inject the API dependency — never import it directly inside the store
// ✅ Receives the API interface — testable with createTestingPinia + mock API
export const useTaskStore = defineStore('task', () => {
const api = inject<TaskAPI>(TASK_API_KEY);
if (!api) throw new Error('[TaskStore] TASK_API_KEY not provided — ensure app.provide() is called before store access');
// ...
});
Use storeToRefs when destructuring a store in components
// ✅ Preserves reactivity
const { tasks, isLoading } = storeToRefs(useTaskStore());
const { loadTasks } = useTaskStore(); // actions don't need storeToRefs
Composables (use* Functions)
Composables are the Vue equivalent of custom hooks — self-contained, reusable units of reactive logic.
Naming: always prefix with use
useTaskFilters, useAuth, usePagination
Return reactive refs, not raw values
// ✅ Caller can use returned values reactively
function useCounter(initial = 0) {
const count = ref(initial);
const increment = () => count.value++;
return { count, increment };
}
// ❌ count is a plain number — not reactive
function useCounter() {
let count = 0;
return { count };
}
Always clean up side effects in onUnmounted
function useWindowResize() {
const width = ref(window.innerWidth);
const handler = () => (width.value = window.innerWidth);
onMounted(() => window.addEventListener('resize', handler));
onUnmounted(() => window.removeEventListener('resize', handler)); // ✅ cleanup
return { width };
}
Template refs with useTemplateRef (Vue 3.5+) — type-safe, IDE-friendly replacement for ref(null)
// ✅ Vue 3.5+ — useTemplateRef provides fully typed access
const inputEl = useTemplateRef<HTMLInputElement>('myInput');
// <input ref="myInput" />
// ❌ Old pattern (before 3.5) — less type-safe
const inputEl = ref<HTMLInputElement | null>(null);
Feature-specific composables live inside the feature directory — global composables go in src/composables/. See references/project-structure.md.
Component Design
defineProps with TypeScript generics — no runtime validators for typed props
const props = defineProps<{
taskId: string;
variant?: 'compact' | 'full';
}>();
// Defaults via withDefaults
const props = withDefaults(defineProps<{ variant?: 'compact' | 'full' }>(), {
variant: 'full',
});
defineEmits with typed event signatures
const emit = defineEmits<{
'update:modelValue': [value: string];
'submit': [task: CreateTaskRequest];
}>();
defineModel (Vue 3.4+) — preferred v-model pattern
<script setup lang="ts">
// ✅ Vue 3.4+ — one line replaces modelValue prop + emit boilerplate
const modelValue = defineModel<string>({ required: true });
// Named models for multi-v-model components
const title = defineModel<string>('title');
const priority = defineModel<'low' | 'medium' | 'high'>('priority', { default: 'medium' });
</script>
<!-- Usage by parent: <TaskForm v-model="name" v-model:priority="prio" /> -->
Pre-3.4 fallback (when defineModel is unavailable):
// ❌ Verbose — use defineModel instead on Vue 3.4+
const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
defineExpose to selectively expose methods to parent refs
// Everything in <script setup> is private by default.
// Use defineExpose only for intentional parent access (e.g., form.reset()).
defineExpose({ reset, focus });
// ❌ Without defineExpose: parent ref.value.reset() will be undefined
v-bind="$attrs" and inheritAttrs: false for forwarding attributes
// Avoid prop drilling for HTML attributes — forward them to the root element
defineOptions({ inheritAttrs: false });
// In template: <input v-bind="$attrs" />
One concern per component — if the template exceeds 100 lines (excluding boilerplate), extract a sub-component
Never put business logic in the template — computed and composables belong in <script setup>
Template Patterns
Always bind :key with stable, unique IDs in v-for — never use index as key when list order can change
<!-- ✅ Stable key -->
<TaskCard v-for="task in tasks" :key="task.id" :task="task" />
<!-- ❌ Index key — causes rerender bugs when list reordered -->
<TaskCard v-for="(task, i) in tasks" :key="i" :task="task" />
Never combine v-if and v-for on the same element — wrap with <template>
<!-- ✅ -->
<template v-for="task in tasks" :key="task.id">
<TaskCard v-if="task.visible" :task="task" />
</template>
Route Transitions
When using <Transition> or <RouterView> with transition effects, CSS frameworks that use @layer (Tailwind v4, Open Props, UnoCSS) can silently break SPA navigation by overriding transition properties in the cascade. This causes transitionend to never fire, permanently blocking the entering component.
Avoid mode="out-in" when using @layer-based CSS frameworks — the leaving component's transitionend event may never fire, blocking the entering component indefinitely. Use simultaneous transitions instead:
<!-- ❌ Dangerous with @layer CSS frameworks -->
<Transition name="fade" mode="out-in">
<component :is="Component" />
</Transition>
<!-- ✅ Safe: simultaneous leave/enter, always mounts new component -->
<Transition name="fade">
<component :is="Component" :key="$route.path" />
</Transition>
Always bind :key="$route.path" on dynamic <component> inside <Transition> — forces Vue to treat each route as a distinct component instance, ensuring proper enter/leave lifecycle
Use !important on route transition CSS classes — guarantees transition properties win the @layer cascade:
.fade-enter-active {
transition: opacity 0.15s ease-in !important;
}
.fade-leave-active {
transition: opacity 0.15s ease-out !important;
position: absolute !important;
width: 100% !important;
top: 0 !important;
left: 0 !important;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0 !important;
}
Give the transition parent position: relative — contains the absolutely-positioned leaving element during the simultaneous transition overlap
For full diagnosis steps when a transition-stuck blank screen occurs, see the Debugging Protocol's Frontend module: @.agents/skills/debugging-protocol/languages/frontend.md § CSS × Animation.
Error Handling
For error type hierarchies, custom error classes, and Result<T, E>, see @.agents/skills/typescript-idioms/SKILL.md §Error Handling. This section covers Vue-specific error handling only.
Global error handler — register at app startup:
// main.ts — catches all unhandled errors in any component
app.config.errorHandler = (err, instance, info) => {
logger.error('Unhandled Vue error', {
error: err instanceof Error ? err.message : String(err),
componentInfo: info,
stack: err instanceof Error ? err.stack : undefined,
});
};
Component-level error capture with onErrorCaptured:
// ✅ Catches errors from child component tree — use for error boundary components
const error = ref<Error | null>(null);
onErrorCaptured((err) => {
error.value = err instanceof Error ? err : new Error(String(err));
return false; // stop propagation to parent
});
Async errors in lifecycle hooks — always handle:
// ❌ Floating promise — error silently lost
onMounted(() => { loadTasks(); });
// ✅ Catch and surface to reactive error state
onMounted(async () => {
try {
await loadTasks();
} catch (err) {
error.value = err instanceof Error ? err : new Error(String(err));
}
});
Form Handling
For Zod schema patterns, see @.agents/skills/typescript-idioms/references/zod-patterns.md. This section covers Vue-specific form binding only.
defineModel for simple forms (Vue 3.4+) — see Component Design §3 above.
VeeValidate + Zod for validated forms:
<script setup lang="ts">
import { useForm, useField } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
const schema = toTypedSchema(z.object({
title: z.string().min(1, 'Title is required').max(200),
priority: z.enum(['low', 'medium', 'high']),
}));
const { handleSubmit, errors } = useForm({ validationSchema: schema });
const { value: title } = useField<string>('title');
const { value: priority } = useField<string>('priority');
const (values) => {
await taskStore.createTask(values);
});
</script>
<template>
<form @submit="onSubmit">
<input v-model="title" />
<span v-if="errors.title">{{ errors.title }}</span>
<select v-model="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button type="submit">Create</button>
</form>
</template>
Client-side validation is UX, not security — always validate at the API boundary too. See @.agents/rules/security-principles.md.
Performance
Profile before optimizing — see @.agents/skills/perf-optimization/SKILL.md for methodology. This section covers Vue-specific patterns only.
defineAsyncComponent for lazy loading heavy components:
import { defineAsyncComponent } from 'vue';
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));
Lazy route loading with Vue Router:
const routes = [
{ path: '/tasks', component: () => import('../views/TaskView.vue') },
{ path: '/settings', component: () => import('../views/SettingsView.vue') },
];
<KeepAlive> for caching expensive component state:
<!-- Caches up to 10 component instances — avoids teardown/remount cost -->
<KeepAlive :max="10">
<component :is="currentTab" />
</KeepAlive>
v-memo for expensive list rendering (Vue 3.2+):
<!-- Re-renders item only when its id or selected state changes -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, item === selected]">
<ExpensiveComponent :item="item" />
</div>
v-once for static content that never changes:
<footer v-once>© 2026 Acme Corp</footer>
Testing
For test naming, pyramid ratios, and the AAA pattern, see @.agents/rules/testing-strategy.md. This section covers Vue-specific tooling only.
Mount wrapper with @vue/test-utils + createTestingPinia:
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
function mountComponent(overrides: Record<string, unknown> = {}) {
return mount(TaskView, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
stubs: { RouterLink: true },
},
...overrides,
});
}
Component interaction — test behaviour, not implementation:
test('calls createTask when form submitted', async () => {
const wrapper = mountComponent();
const store = useTaskStore();
await wrapper.find('[data-testid="title-input"]').setValue('New Task');
await wrapper.find('form').trigger('submit');
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({ title: 'New Task' }),
);
});
Test composables in isolation:
import { createApp } from 'vue';
/** Runs a composable inside a throwaway component context. */
function withSetup<T>(composable: () => T): [T, ReturnType<typeof createApp>] {
let result!: T;
const app = createApp({
setup() { result = composable(); return () => {}; },
});
app.mount(document.createElement('div'));
return [result, app];
}
test('useCounter increments', () => {
const [{ count, increment }] = withSetup(() => useCounter(0));
expect(count.value).toBe(0);
increment();
expect(count.value).toBe(1);
});
Test Pinia stores independently:
import { setActivePinia, createPinia } from 'pinia';
beforeEach(() => { setActivePinia(createPinia()); });
test('loadTasks populates store', async () => {
const store = useTaskStore();
await store.loadTasks();
expect(store.tasks).toHaveLength(3);
});
Snapshot testing for complex output:
test('renders task card correctly', () => {
const wrapper = mountComponent({ props: { task: mockTask } });
expect(wrapper.html()).toMatchSnapshot();
});
Feedback Loop — Development Workflow
Critical: Use vue-tsc --noEmit instead of tsc --noEmit for Vue projects.
tsc cannot type-check .vue <template> blocks — template errors will be invisible.
| Phase |
Command |
Purpose |
| TDD / rapid iteration |
vue-tsc --noEmit |
Type-check templates + scripts — fastest loop |
| Pre-commit |
eslint . |
Static analysis (eslint-plugin-vue required) — zero warnings |
| Pre-commit |
prettier --write . |
Format — non-negotiable |
| Pre-commit |
vitest run |
Unit tests — must all pass |
| Coverage verification |
vitest run --coverage |
Verify before merging |
Rules:
- Never use
tsc --noEmit on Vue projects — it skips all .vue template checking.
eslint-plugin-vue must be configured with plugin:vue/vue3-recommended or stricter.
prettier must handle .vue files (it does by default).
Anti-Patterns
Quick reference — if you're about to do any of these, stop and use the recommended pattern.
- ❌ Options API in new code — always use
<script setup lang="ts">
- ❌ Destructuring reactive objects — loses reactivity; use
toRefs() or storeToRefs()
- ❌ Side effects in
computed — computed must be pure; use watch or watchEffect
- ❌
v-if + v-for on the same element — wrap with <template>
- ❌
:key="index" on dynamic lists — use stable unique IDs
- ❌ Direct store mutation from components — use store actions
- ❌ Business logic in
<template> — move to computed or composables
- ❌
tsc --noEmit on Vue projects — use vue-tsc --noEmit (template checking)
- ❌
ref(null) for template refs in Vue 3.5+ — use useTemplateRef() instead
- ❌ Verbose
modelValue + emit in Vue 3.4+ — use defineModel() instead
- ❌ Importing API clients directly in stores — inject via
inject() for testability
- ❌
watch for derived state — use computed instead (it's cached and more efficient)
Related Principles
- Code Idioms and Conventions @.agents/rules/code-idioms-and-conventions.md
- TypeScript Idioms and Patterns @.agents/skills/typescript-idioms/SKILL.md
- Project Structure — Vue Frontend @.agents/skills/vue-idioms/references/project-structure.md
- Frontend Layout (framework-neutral, shared with React) @.agents/skills/frontend-design/references/frontend-layout.md
- Frontend Design @.agents/skills/frontend-design/SKILL.md
- Security Principles @.agents/rules/security-principles.md
- Accessibility Principles @.agents/rules/accessibility-principles.md
- Architectural Patterns — Testability-First Design @.agents/rules/architectural-pattern.md
- Testing Strategy @.agents/rules/testing-strategy.md
- Error Handling Principles @.agents/rules/error-handling-principles.md
- Logging and Observability Principles @.agents/skills/logging-implementation/SKILL.md
1---2name: vue-idioms-23description: Vue 3 Composition API, Pinia stores, composables, Vite, Vitest.4---56## Vue Idioms and Patterns78### Core Philosophy910Vue 3 Composition API is the default for all new code. `<script setup>` is the canonical syntax. Think in terms of reactive *data flows*, not component lifecycle hooks. Composables (`use*` functions) are the primary unit of logic reuse.1112> **Scope:** This file covers Vue 3 *coding idioms* for components, stores, and composables. For TypeScript type system patterns, see `@.agents/skills/typescript-idioms/SKILL.md`. For file and folder layout, see `references/project-structure.md` (and the shared `@.agents/skills/frontend-design/references/frontend-layout.md`). For test naming, see `@.agents/rules/testing-strategy.md`. For logging, see `@.agents/skills/logging-implementation/SKILL.md`.13>14> **Loading guard:** Do NOT load this skill for non-Vue projects. React → `react-idioms`; Angular → `angular-idioms`; Next.js → `nextjs-idioms`. This skill co-loads with `typescript-idioms` (required for any Vue work).1516## When to Load References1718> **Always load `typescript-idioms` first** — it is required alongside this skill for any Vue work.19> Load these **before** writing code in the matching context — not after.2021| Situation | Reference to Load |22|---|---|23| TypeScript type system, async, Zod, error types | `@.agents/skills/typescript-idioms/SKILL.md` (always co-load) |24| Starting a new Vue project or reviewing file layout | `references/project-structure.md` |25| Choosing Vue ecosystem package versions, Vite/Vitest config | `references/recommended-dependencies.md` |26| Defining Zod schemas or validating boundaries | `@.agents/skills/typescript-idioms/references/zod-patterns.md` |27| Writing code that handles user input, async, or I/O | `@.agents/skills/typescript-idioms/references/ts-patterns-and-anti-patterns.md` |2829### Toolchain and Version Milestones3031> Default to the latest Vue 3 stable. As of July 2026, Vue **3.5+** with Vite 6+.3233**Key version milestones that affect this skill:**34- **3.5+** — `useTemplateRef` (type-safe template refs), improved `useId`, Suspense stable35- **3.4+** — `defineModel` (replaces verbose v-model boilerplate), improved `watch` generics36- **3.3+** — `defineOptions`, `defineSlots`, generic components with `<script setup>`37- **3.2+** — `<script setup>` syntax finalized3839> For recommended package versions and starter configs, see `references/recommended-dependencies.md`.4041---4243### `<script setup>` — The Only Style4445Always use `<script setup lang="ts">`. Never use the Options API or the class-style component pattern for new code.4647```vue48<!-- ✅ Canonical style -->49<script setup lang="ts">50import { ref, computed } from 'vue';5152const props = defineProps<{ title: string; count?: number }>();53const emit = defineEmits<{ 'update:count': [value: number] }>();5455const doubled = computed(() => (props.count ?? 0) * 2);56</script>5758<!-- ❌ Options API — do not use for new components -->59<script lang="ts">60export default { props: { title: String }, ... }61</script>62```6364---6566### Reactivity: `ref` vs `reactive`6768| Use | When |69| ------------ | ---------------------------------------------------------------------------------- |70| `ref<T>()` | Primitives, single values, values that may be reassigned |71| `reactive()` | Plain objects where you always access properties (never reassign the whole object) |72| `readonly()` | Expose state that must not be mutated outside its owner |7374```typescript75// ✅ ref for primitives and replaceable objects76const count = ref(0);77const user = ref<User | null>(null);78user.value = fetchedUser; // reassignment is fine7980// ✅ reactive for objects where you destructure properties81const form = reactive({ title: '', priority: 'medium' });8283// ❌ Never destructure a reactive object — reactivity is lost84const { title } = form; // title is now a plain string, NOT reactive85// ✅ Use toRefs if you must destructure86const { title } = toRefs(form);87```8889---9091### Computed Properties92931. **Use `computed` for all derived state** — never recompute in the template94 ```typescript95 // ✅ Cached, reactive96 const filteredTasks = computed(() =>97 tasks.value.filter(t => t.status === activeFilter.value)98 );99100 // ❌ Recomputes on every render101 // <template>{{ tasks.filter(t => t.status === filter) }}</template>102 ```1031042. **Never cause side effects inside `computed`** — computed must be pure105 ```typescript106 // ❌ Side effect in computed107 const count = computed(() => {108 taskStore.logAccess(); // NO — this is a side effect109 return tasks.value.length;110 });111 ```1121133. **Use writable computed for two-way bindings**114 ```typescript115 const modelValue = computed({116 get: () => props.modelValue,117 set: (val) => emit('update:modelValue', val),118 });119 ```120121---122123### Watch Strategy124125Use the most precise watcher for the situation — over-watching is a performance and correctness problem.126127| Watcher | Use When |128| ------------- | --------------------------------------------------------------------------------------------------------- |129| `watchEffect` | Side effect that should re-run whenever any of its reactive dependencies change; auto-tracks dependencies |130| `watch` | You need the old value, lazy execution, or want to watch a specific source explicitly |131| `computed` | You need a synchronous derived value (prefer this over `watch` for transformation) |132133```typescript134// ✅ watchEffect — auto-tracks dependencies135watchEffect(() => {136 document.title = `Tasks (${count.value})`;137});138139// ✅ watch — explicit source, has old value140watch(userId, async (newId, oldId) => {141 if (newId !== oldId) await loadUser(newId);142}, { immediate: true });143144// ❌ Avoid using watch just for computed values145watch(tasks, () => { filteredCount.value = tasks.value.filter(...).length; });146// ✅ Use computed instead147const filteredCount = computed(() => tasks.value.filter(...).length);148```149150---151152### Pinia Stores153154> The store directory structure is defined in `references/project-structure.md`. This section covers Pinia coding idioms.1551561. **Use the Setup Store API** (not Options API) for new stores157 ```typescript158 // task/store/task.store.ts159 export const useTaskStore = defineStore('task', () => {160 // State161 const tasks = ref<Task[]>([]);162 const isLoading = ref(false);163164 // Getters (computed)165 const completedTasks = computed(() =>166 tasks.value.filter(t => t.status === 'done')167 );168169 // Actions170 async function loadTasks() {171 isLoading.value = true;172 try {173 tasks.value = await taskAPI.getTasks();174 } finally {175 isLoading.value = false;176 }177 }178179 return { tasks, isLoading, completedTasks, loadTasks };180 });181 ```1821832. **Never mutate store state from outside the store**184 ```typescript185 // ❌ Direct mutation from a component186 const store = useTaskStore();187 store.tasks.push(newTask); // NO188189 // ✅ Call an action190 await store.addTask(newTask);191 ```1921933. **Inject the API dependency — never import it directly inside the store**194 ```typescript195 // ✅ Receives the API interface — testable with createTestingPinia + mock API196 export const useTaskStore = defineStore('task', () => {197 const api = inject<TaskAPI>(TASK_API_KEY);198 if (!api) throw new Error('[TaskStore] TASK_API_KEY not provided — ensure app.provide() is called before store access');199 // ...200 });201 ```2022034. **Use `storeToRefs` when destructuring a store in components**204 ```typescript205 // ✅ Preserves reactivity206 const { tasks, isLoading } = storeToRefs(useTaskStore());207 const { loadTasks } = useTaskStore(); // actions don't need storeToRefs208 ```209210---211212### Composables (`use*` Functions)213214Composables are the Vue equivalent of custom hooks — self-contained, reusable units of reactive logic.2152161. **Naming: always prefix with `use`**217 - `useTaskFilters`, `useAuth`, `usePagination`2182192. **Return reactive refs, not raw values**220 ```typescript221 // ✅ Caller can use returned values reactively222 function useCounter(initial = 0) {223 const count = ref(initial);224 const increment = () => count.value++;225 return { count, increment };226 }227228 // ❌ count is a plain number — not reactive229 function useCounter() {230 let count = 0;231 return { count };232 }233 ```2342353. **Always clean up side effects in `onUnmounted`**236 ```typescript237 function useWindowResize() {238 const width = ref(window.innerWidth);239 const handler = () => (width.value = window.innerWidth);240241 onMounted(() => window.addEventListener('resize', handler));242 onUnmounted(() => window.removeEventListener('resize', handler)); // ✅ cleanup243 return { width };244 }245 ```2462474. **Template refs with `useTemplateRef` (Vue 3.5+)** — type-safe, IDE-friendly replacement for `ref(null)`248 ```typescript249 // ✅ Vue 3.5+ — useTemplateRef provides fully typed access250 const inputEl = useTemplateRef<HTMLInputElement>('myInput');251 // <input ref="myInput" />252253 // ❌ Old pattern (before 3.5) — less type-safe254 const inputEl = ref<HTMLInputElement | null>(null);255 ```2562575. **Feature-specific composables live inside the feature directory** — global composables go in `src/composables/`. See `references/project-structure.md`.258259---260261### Component Design2622631. **`defineProps` with TypeScript generics — no runtime validators for typed props**264 ```typescript265 const props = defineProps<{266 taskId: string;267 variant?: 'compact' | 'full';268 }>();269270 // Defaults via withDefaults271 const props = withDefaults(defineProps<{ variant?: 'compact' | 'full' }>(), {272 variant: 'full',273 });274 ```2752762. **`defineEmits` with typed event signatures**277 ```typescript278 const emit = defineEmits<{279 'update:modelValue': [value: string];280 'submit': [task: CreateTaskRequest];281 }>();282 ```2832843. **`defineModel` (Vue 3.4+) — preferred v-model pattern**285 ```vue286 <script setup lang="ts">287 // ✅ Vue 3.4+ — one line replaces modelValue prop + emit boilerplate288 const modelValue = defineModel<string>({ required: true });289290 // Named models for multi-v-model components291 const title = defineModel<string>('title');292 const priority = defineModel<'low' | 'medium' | 'high'>('priority', { default: 'medium' });293 </script>294295 <!-- Usage by parent: <TaskForm v-model="name" v-model:priority="prio" /> -->296 ```297298 Pre-3.4 fallback (when `defineModel` is unavailable):299 ```typescript300 // ❌ Verbose — use defineModel instead on Vue 3.4+301 const props = defineProps<{ modelValue: string }>();302 const emit = defineEmits<{ 'update:modelValue': [value: string] }>();303 ```3043054. **`defineExpose` to selectively expose methods to parent refs**306 ```typescript307 // Everything in <script setup> is private by default.308 // Use defineExpose only for intentional parent access (e.g., form.reset()).309 defineExpose({ reset, focus });310 // ❌ Without defineExpose: parent ref.value.reset() will be undefined311 ```3123135. **`v-bind="$attrs"` and `inheritAttrs: false` for forwarding attributes**314 ```typescript315 // Avoid prop drilling for HTML attributes — forward them to the root element316 defineOptions({ inheritAttrs: false });317 // In template: <input v-bind="$attrs" />318 ```3193206. **One concern per component** — if the template exceeds 100 lines (excluding boilerplate), extract a sub-component3213227. **Never put business logic in the template** — computed and composables belong in `<script setup>`323324---325326### Template Patterns3273281. **Always bind `:key` with stable, unique IDs in `v-for`** — never use index as key when list order can change329 ```html330 <!-- ✅ Stable key -->331 <TaskCard v-for="task in tasks" :key="task.id" :task="task" />332333 <!-- ❌ Index key — causes rerender bugs when list reordered -->334 <TaskCard v-for="(task, i) in tasks" :key="i" :task="task" />335 ```3363372. **Never combine `v-if` and `v-for` on the same element** — wrap with `<template>`338 ```html339 <!-- ✅ -->340 <template v-for="task in tasks" :key="task.id">341 <TaskCard v-if="task.visible" :task="task" />342 </template>343 ```344345---346347### Route Transitions348349When using `<Transition>` or `<RouterView>` with transition effects, CSS frameworks that use `@layer` (Tailwind v4, Open Props, UnoCSS) can silently break SPA navigation by overriding transition properties in the cascade. This causes `transitionend` to never fire, permanently blocking the entering component.3503511. **Avoid `mode="out-in"` when using `@layer`-based CSS frameworks** — the leaving component's `transitionend` event may never fire, blocking the entering component indefinitely. Use simultaneous transitions instead:352 ```html353 <!-- ❌ Dangerous with @layer CSS frameworks -->354 <Transition name="fade" mode="out-in">355 <component :is="Component" />356 </Transition>357358 <!-- ✅ Safe: simultaneous leave/enter, always mounts new component -->359 <Transition name="fade">360 <component :is="Component" :key="$route.path" />361 </Transition>362 ```3633642. **Always bind `:key="$route.path"`** on dynamic `<component>` inside `<Transition>` — forces Vue to treat each route as a distinct component instance, ensuring proper enter/leave lifecycle3653663. **Use `!important` on route transition CSS classes** — guarantees transition properties win the `@layer` cascade:367 ```css368 .fade-enter-active {369 transition: opacity 0.15s ease-in !important;370 }371 .fade-leave-active {372 transition: opacity 0.15s ease-out !important;373 position: absolute !important;374 width: 100% !important;375 top: 0 !important;376 left: 0 !important;377 }378 .fade-enter-from,379 .fade-leave-to {380 opacity: 0 !important;381 }382 ```3833844. **Give the transition parent `position: relative`** — contains the absolutely-positioned leaving element during the simultaneous transition overlap385386> For full diagnosis steps when a transition-stuck blank screen occurs, see the Debugging Protocol's Frontend module: `@.agents/skills/debugging-protocol/languages/frontend.md` § CSS × Animation.387388---389390### Error Handling391392> For error type hierarchies, custom error classes, and `Result<T, E>`, see `@.agents/skills/typescript-idioms/SKILL.md` §Error Handling. This section covers Vue-specific error handling only.3933941. **Global error handler — register at app startup:**395 ```typescript396 // main.ts — catches all unhandled errors in any component397 app.config.errorHandler = (err, instance, info) => {398 logger.error('Unhandled Vue error', {399 error: err instanceof Error ? err.message : String(err),400 componentInfo: info,401 stack: err instanceof Error ? err.stack : undefined,402 });403 };404 ```4054062. **Component-level error capture with `onErrorCaptured`:**407 ```typescript408 // ✅ Catches errors from child component tree — use for error boundary components409 const error = ref<Error | null>(null);410 onErrorCaptured((err) => {411 error.value = err instanceof Error ? err : new Error(String(err));412 return false; // stop propagation to parent413 });414 ```4154163. **Async errors in lifecycle hooks — always handle:**417 ```typescript418 // ❌ Floating promise — error silently lost419 onMounted(() => { loadTasks(); });420421 // ✅ Catch and surface to reactive error state422 onMounted(async () => {423 try {424 await loadTasks();425 } catch (err) {426 error.value = err instanceof Error ? err : new Error(String(err));427 }428 });429 ```430431---432433### Form Handling434435> For Zod schema patterns, see `@.agents/skills/typescript-idioms/references/zod-patterns.md`. This section covers Vue-specific form binding only.4364371. **`defineModel` for simple forms (Vue 3.4+)** — see Component Design §3 above.4384392. **VeeValidate + Zod for validated forms:**440 ```vue441 <script setup lang="ts">442 import { useForm, useField } from 'vee-validate';443 import { toTypedSchema } from '@vee-validate/zod';444 import { z } from 'zod';445446 const schema = toTypedSchema(z.object({447 title: z.string().min(1, 'Title is required').max(200),448 priority: z.enum(['low', 'medium', 'high']),449 }));450451 const { handleSubmit, errors } = useForm({ validationSchema: schema });452 const { value: title } = useField<string>('title');453 const { value: priority } = useField<string>('priority');454455 const onSubmit = handleSubmit(async (values) => {456 await taskStore.createTask(values);457 });458 </script>459460 <template>461 <form @submit="onSubmit">462 <input v-model="title" />463 <span v-if="errors.title">{{ errors.title }}</span>464 <select v-model="priority">465 <option value="low">Low</option>466 <option value="medium">Medium</option>467 <option value="high">High</option>468 </select>469 <button type="submit">Create</button>470 </form>471 </template>472 ```4734743. **Client-side validation is UX, not security** — always validate at the API boundary too. See `@.agents/rules/security-principles.md`.475476---477478### Performance479480> Profile before optimizing — see `@.agents/skills/perf-optimization/SKILL.md` for methodology. This section covers Vue-specific patterns only.4814821. **`defineAsyncComponent` for lazy loading heavy components:**483 ```typescript484 import { defineAsyncComponent } from 'vue';485 const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));486 ```4874882. **Lazy route loading with Vue Router:**489 ```typescript490 const routes = [491 { path: '/tasks', component: () => import('../views/TaskView.vue') },492 { path: '/settings', component: () => import('../views/SettingsView.vue') },493 ];494 ```4954963. **`<KeepAlive>` for caching expensive component state:**497 ```html498 <!-- Caches up to 10 component instances — avoids teardown/remount cost -->499 <KeepAlive :max="10">500 <component :is="currentTab" />501 </KeepAlive>502 ```5035044. **`v-memo` for expensive list rendering (Vue 3.2+):**505 ```html506 <!-- Re-renders item only when its id or selected state changes -->507 <div v-for="item in list" :key="item.id" v-memo="[item.id, item === selected]">508 <ExpensiveComponent :item="item" />509 </div>510 ```5115125. **`v-once` for static content that never changes:**513 ```html514 <footer v-once>© 2026 Acme Corp</footer>515 ```516517---518519### Testing520521> For test naming, pyramid ratios, and the AAA pattern, see `@.agents/rules/testing-strategy.md`. This section covers **Vue-specific tooling only**.5225231. **Mount wrapper with `@vue/test-utils` + `createTestingPinia`:**524 ```typescript525 import { mount } from '@vue/test-utils';526 import { createTestingPinia } from '@pinia/testing';527 import { vi } from 'vitest';528529 function mountComponent(overrides: Record<string, unknown> = {}) {530 return mount(TaskView, {531 global: {532 plugins: [createTestingPinia({ createSpy: vi.fn })],533 stubs: { RouterLink: true },534 },535 ...overrides,536 });537 }538 ```5395402. **Component interaction — test behaviour, not implementation:**541 ```typescript542 test('calls createTask when form submitted', async () => {543 const wrapper = mountComponent();544 const store = useTaskStore();545546 await wrapper.find('[data-testid="title-input"]').setValue('New Task');547 await wrapper.find('form').trigger('submit');548549 expect(store.createTask).toHaveBeenCalledWith(550 expect.objectContaining({ title: 'New Task' }),551 );552 });553 ```5545553. **Test composables in isolation:**556 ```typescript557 import { createApp } from 'vue';558559 /** Runs a composable inside a throwaway component context. */560 function withSetup<T>(composable: () => T): [T, ReturnType<typeof createApp>] {561 let result!: T;562 const app = createApp({563 setup() { result = composable(); return () => {}; },564 });565 app.mount(document.createElement('div'));566 return [result, app];567 }568569 test('useCounter increments', () => {570 const [{ count, increment }] = withSetup(() => useCounter(0));571 expect(count.value).toBe(0);572 increment();573 expect(count.value).toBe(1);574 });575 ```5765774. **Test Pinia stores independently:**578 ```typescript579 import { setActivePinia, createPinia } from 'pinia';580581 beforeEach(() => { setActivePinia(createPinia()); });582583 test('loadTasks populates store', async () => {584 const store = useTaskStore();585 await store.loadTasks();586 expect(store.tasks).toHaveLength(3);587 });588 ```5895905. **Snapshot testing for complex output:**591 ```typescript592 test('renders task card correctly', () => {593 const wrapper = mountComponent({ props: { task: mockTask } });594 expect(wrapper.html()).toMatchSnapshot();595 });596 ```597598---599600### Feedback Loop — Development Workflow601602> **Critical:** Use `vue-tsc --noEmit` instead of `tsc --noEmit` for Vue projects.603> `tsc` cannot type-check `.vue` `<template>` blocks — template errors will be **invisible**.604605| Phase | Command | Purpose |606|---|---|---|607| TDD / rapid iteration | `vue-tsc --noEmit` | Type-check templates + scripts — fastest loop |608| Pre-commit | `eslint .` | Static analysis (`eslint-plugin-vue` required) — **zero warnings** |609| Pre-commit | `prettier --write .` | Format — non-negotiable |610| Pre-commit | `vitest run` | Unit tests — must all pass |611| Coverage verification | `vitest run --coverage` | Verify before merging |612613**Rules:**614- **Never** use `tsc --noEmit` on Vue projects — it skips all `.vue` template checking.615- `eslint-plugin-vue` must be configured with `plugin:vue/vue3-recommended` or stricter.616- `prettier` must handle `.vue` files (it does by default).617618---619620### Anti-Patterns621622> Quick reference — if you're about to do any of these, stop and use the recommended pattern.623624- ❌ **Options API in new code** — always use `<script setup lang="ts">`625- ❌ **Destructuring reactive objects** — loses reactivity; use `toRefs()` or `storeToRefs()`626- ❌ **Side effects in `computed`** — computed must be pure; use `watch` or `watchEffect`627- ❌ **`v-if` + `v-for` on the same element** — wrap with `<template>`628- ❌ **`:key="index"` on dynamic lists** — use stable unique IDs629- ❌ **Direct store mutation from components** — use store actions630- ❌ **Business logic in `<template>`** — move to `computed` or composables631- ❌ **`tsc --noEmit` on Vue projects** — use `vue-tsc --noEmit` (template checking)632- ❌ **`ref(null)` for template refs in Vue 3.5+** — use `useTemplateRef()` instead633- ❌ **Verbose `modelValue` + emit in Vue 3.4+** — use `defineModel()` instead634- ❌ **Importing API clients directly in stores** — inject via `inject()` for testability635- ❌ **`watch` for derived state** — use `computed` instead (it's cached and more efficient)636637---638639### Related Principles640- Code Idioms and Conventions @.agents/rules/code-idioms-and-conventions.md641- TypeScript Idioms and Patterns @.agents/skills/typescript-idioms/SKILL.md642- Project Structure — Vue Frontend @.agents/skills/vue-idioms/references/project-structure.md643- Frontend Layout (framework-neutral, shared with React) @.agents/skills/frontend-design/references/frontend-layout.md644- Frontend Design @.agents/skills/frontend-design/SKILL.md645- Security Principles @.agents/rules/security-principles.md646- Accessibility Principles @.agents/rules/accessibility-principles.md647- Architectural Patterns — Testability-First Design @.agents/rules/architectural-pattern.md648- Testing Strategy @.agents/rules/testing-strategy.md649- Error Handling Principles @.agents/rules/error-handling-principles.md650- Logging and Observability Principles @.agents/skills/logging-implementation/SKILL.md