Vue Idioms and Patterns
Vue 3 Composition API default. <script setup> canonical syntax. Think reactive data flows, not lifecycle hooks. Composables (use*) = primary logic reuse unit.
Scope: Vue 3 coding idioms. TS type system: @.gemini/skills/typescript-idioms/SKILL.md. Layout: @.gemini/skills/project-structure-vue/SKILL.md. Tests: GEMINI.md § Testing Strategy. Logging: @.gemini/skills/logging-and-observability-principles/SKILL.md.
<script setup> — Only Style
Always <script setup lang="ts">. Never Options API or class-style for new code.
Canonical ordering inside <script setup>:
<script setup lang="ts">
// 1. Framework imports
import { ref, computed, onMounted } from 'vue';
// 2. Third-party imports
import { useIntersectionObserver } from '@vueuse/core';
// 3. Internal imports — feature-relative paths
import type { Task } from './types';
import { useAuth } from '@/composables/useAuth';
// 4. Props & Emits — typed interfaces
interface Props {
title: string;
variant?: 'compact' | 'full';
}
const props = withDefaults(defineProps<Props>(), { variant: 'full' });
const emit = defineEmits<{ select: [item: Task]; close: [] }>();
// 5. Composables
const { user } = useAuth();
// 6. Reactive state
const isVisible = ref(false);
// 7. Computed
const doubled = computed(() => (props.count ?? 0) * 2);
// 8. Methods — always named functions (not arrow)
function handleSelect(item: Task) {
emit('select', item);
}
// 9. Lifecycle (always last before template)
onMounted(() => { /* setup */ });
</script>
<!-- ❌ Options API — not for new components -->
<script lang="ts">
export default { props: { title: String }, ... }
</script>
Named functions for methods: Use function handleClick() — not const handleClick = () =>. Named functions produce readable stack traces, hoist predictably, and clearly signal "this is an action". Reserve arrow functions for callbacks and inline expressions.
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 whole object) |
readonly() |
State that must not be mutated outside owner |
// ✅ ref for primitives and replaceable objects
const count = ref(0);
const user = ref<User | null>(null);
user.value = fetchedUser;
// ✅ reactive for objects
const form = reactive({ title: '', priority: 'medium' });
// ❌ Never destructure reactive — reactivity lost
const { title } = form;
// ✅ Use toRefs
const { title } = toRefs(form);
Computed
All derived state via computed — never recompute in template:
const filteredTasks = computed(() =>
tasks.value.filter(t => t.status === activeFilter.value)
);
No side effects in computed — must be pure.
Writable computed — escape-hatch for complex two-way bindings only. For standard v-model, use defineModel() (see Component Design below):
// Only when defineModel() is insufficient (e.g., cross-store sync)
const modelValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
Watch Strategy
| Watcher |
When |
watchEffect |
Re-run on any dependency change; auto-tracks |
watch |
Need old value, lazy execution, or explicit source |
computed |
Synchronous derived value (prefer over watch for transformation) |
// watchEffect — auto-tracks
watchEffect(() => {
document.title = `Tasks (${count.value})`;
});
// watch — explicit source
watch(userId, async (newId, oldId) => {
if (newId !== oldId) await loadUser(newId);
}, { immediate: true });
Pinia Stores
Directory structure: @.gemini/skills/project-structure-vue/SKILL.md. Coding idioms below.
Setup Store API (not Options):
export const useTaskStore = defineStore('task', () => {
const tasks = ref<Task[]>([]);
const isLoading = ref(false);
const completedTasks = computed(() =>
tasks.value.filter(t => t.status === 'done')
);
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 — call actions.
Inject API dependency — never import directly inside store:
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');
// ...
});
storeToRefs for destructuring:
const { tasks, isLoading } = storeToRefs(useTaskStore());
const { loadTasks } = useTaskStore(); // actions don't need storeToRefs
Composables (use*)
Naming: always prefix use — useTaskFilters, useAuth, usePagination.
Return reactive refs:
function useCounter(initial = 0) {
const count = ref(initial);
function increment() { count.value++; }
return { count, increment };
}
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));
return { width };
}
Template refs with useTemplateRef (Vue 3.5+):
const inputEl = useTemplateRef<HTMLInputElement>('myInput');
// <input ref="myInput" />
Feature-specific composables inside feature dir. Global composables in src/composables/.
Component Design
defineProps with TS generics:
// Omit `const props =` if props are only used in template
defineProps<{ title: string; count?: number }>();
// Use `const props =` only if props are accessed in <script setup>
const props = defineProps<{ taskId: string; variant?: 'compact' | 'full' }>();
Prop defaults — destructuring (Vue 3.5+) or withDefaults:
// ✅ Preferred: destructure with defaults (Vue 3.5+)
const { title = 'Untitled', variant = 'full' } = defineProps<{
title?: string;
variant?: 'compact' | 'full';
}>();
// ✅ Also valid: withDefaults
const props = withDefaults(defineProps<{ variant?: 'compact' | 'full' }>(), {
variant: 'full',
});
defineEmits typed:
const emit = defineEmits<{
submit: [task: CreateTaskRequest];
close: [];
}>();
defineModel() for v-model (Vue 3.4+):
// ✅ Simple two-way binding — replaces manual modelValue prop + emit
const title = defineModel<string>();
// ✅ With options and modifiers
const [title, modifiers] = defineModel<string>({
default: 'default value',
required: true,
get: (value) => value.trim(),
set: (value) => modifiers.capitalize
? value.charAt(0).toUpperCase() + value.slice(1) : value,
});
// ✅ Multiple v-model bindings
const firstName = defineModel<string>('firstName');
const age = defineModel<number>('age');
// Usage: <UserForm v-model:first-name="user.firstName" v-model:age="user.age" />
defineExpose for selective parent access. Everything private by default.
v-bind="$attrs" + inheritAttrs: false for attribute forwarding.
One concern per component. Template over 100 lines -> extract sub-component.
No business logic in template — computed/composables in <script setup>.
Template Conventions
:key with stable unique IDs in v-for — never index when list order changes:
<TaskCard v-for="task in tasks" :key="task.id" :task="task" />
Never combine v-if and v-for on same element — wrap with <template>.
Prop shorthand — when value matches prop name:
<!-- ✅ Shorthand -->
<MyComponent :count />
<!-- ❌ Redundant -->
<MyComponent :count="count" />
Slot shorthand — # over v-slot::
<!-- ✅ -->
<template #header>...</template>
<template #default>...</template>
<!-- ❌ -->
<template v-slot:header>...</template>
Explicit <template> tags for ALL used slots — never rely on implicit default.
Case conventions: camelCase in JS (props, emits), kebab-case in templates:
<!-- Template: kebab-case -->
<UserCard :first-name="name" @update-profile="handleUpdate" />
// Script: camelCase
defineProps<{ firstName: string }>();
defineEmits<{ updateProfile: [] }>();
Component naming direction: General → Specific — SearchButtonClear.vue not ClearSearchButton.vue. Mirrors natural language hierarchy.
Route Transitions
CSS frameworks with @layer (Tailwind v4, Open Props, UnoCSS) can break SPA navigation by overriding transition properties. transitionend never fires -> entering component permanently blocked.
Avoid mode="out-in" with @layer frameworks. Use simultaneous:
<!-- ✅ Safe -->
<Transition name="fade">
<component :is="Component" :key="$route.path" />
</Transition>
Always :key="$route.path" on dynamic component inside Transition.
!important on route transition CSS:
.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;
}
Transition parent needs position: relative.
Diagnosis: Debugging Protocol Frontend module § CSS × Animation.
File-Based Routing
Modern Vue projects use file-based routing where the file/folder structure defines routes. These conventions apply regardless of the specific tool (Unplugin Vue Router, Nuxt, etc.):
Avoid index.vue — use route groups for descriptive names:
src/pages/
├── (home).vue # Renders at / — descriptive name
├── about.vue # Renders at /about
├── [...path].vue # Catch-all (404)
├── users.vue # Layout for nested user routes
└── users/
├── (user-list).vue # Renders at /users
└── [userId].vue # Renders at /users/:userId
Named params over generic — [userId] not [id], [postSlug] not [slug].
Dot notation for flat routes — users.edit.vue → /users/edit without nesting.
Route groups for shared layouts without affecting URL:
src/pages/
├── (admin).vue # Layout for admin routes
├── (admin)/
│ ├── dashboard.vue # /dashboard
│ └── settings.vue # /settings
Typed route navigation — prefer named route locations:
// ✅ Type-safe, refactor-safe
router.push({ name: '/users/[userId]', params: { userId } });
// ❌ String concatenation — fragile
router.push('/users/' + userId);
definePage() to customize route properties (meta, name, alias) inline.
Check typed-router.d.ts for available route names and param types.
Testing
Naming/pyramid: GEMINI.md § Testing Strategy. Vue-specific below.
createTestingPinia for component tests:
import { vi } from 'vitest';
const wrapper = mount(TaskView, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
},
});
Test behavior, not implementation — query by accessible role, not CSS class.
Test stores independently — setActivePinia(createPinia()).
Linting and Type Checking
| Tool |
Purpose |
vue-tsc --noEmit |
Full-template type checking |
eslint-plugin-vue |
Vue-specific lint rules |
prettier |
Canonical formatting |
See GEMINI.md § Code Completion Mandate for exact commands.
Related
- Code Idioms and Conventions GEMINI.md § Code Idioms and Conventions
- TypeScript Idioms and Patterns @.gemini/skills/typescript-idioms/SKILL.md
- Project Structure — Vue Frontend @.gemini/skills/project-structure-vue/SKILL.md
- Architectural Patterns GEMINI.md § Architectural Patterns
- Testing Strategy GEMINI.md § Testing Strategy
- Logging and Observability Principles @.gemini/skills/logging-and-observability-principles/SKILL.md
1---2name: vue-idioms3description: Vue Idioms and Patterns4---56## Vue Idioms and Patterns78Vue 3 Composition API default. `<script setup>` canonical syntax. Think reactive data flows, not lifecycle hooks. Composables (`use*`) = primary logic reuse unit.910> Scope: Vue 3 coding idioms. TS type system: `@.gemini/skills/typescript-idioms/SKILL.md`. Layout: `@.gemini/skills/project-structure-vue/SKILL.md`. Tests: GEMINI.md § Testing Strategy. Logging: `@.gemini/skills/logging-and-observability-principles/SKILL.md`.1112### `<script setup>` — Only Style1314Always `<script setup lang="ts">`. Never Options API or class-style for new code.1516**Canonical ordering inside `<script setup>`:**1718```vue19<script setup lang="ts">20// 1. Framework imports21import { ref, computed, onMounted } from 'vue';2223// 2. Third-party imports24import { useIntersectionObserver } from '@vueuse/core';2526// 3. Internal imports — feature-relative paths27import type { Task } from './types';28import { useAuth } from '@/composables/useAuth';2930// 4. Props & Emits — typed interfaces31interface Props {32 title: string;33 variant?: 'compact' | 'full';34}35const props = withDefaults(defineProps<Props>(), { variant: 'full' });36const emit = defineEmits<{ select: [item: Task]; close: [] }>();3738// 5. Composables39const { user } = useAuth();4041// 6. Reactive state42const isVisible = ref(false);4344// 7. Computed45const doubled = computed(() => (props.count ?? 0) * 2);4647// 8. Methods — always named functions (not arrow)48function handleSelect(item: Task) {49 emit('select', item);50}5152// 9. Lifecycle (always last before template)53onMounted(() => { /* setup */ });54</script>5556<!-- ❌ Options API — not for new components -->57<script lang="ts">58export default { props: { title: String }, ... }59</script>60```6162**Named functions for methods:** Use `function handleClick()` — not `const handleClick = () =>`. Named functions produce readable stack traces, hoist predictably, and clearly signal "this is an action". Reserve arrow functions for callbacks and inline expressions.6364### Reactivity: `ref` vs `reactive`6566| Use | When |67|---|---|68| `ref<T>()` | Primitives, single values, values that may be reassigned |69| `reactive()` | Plain objects where you always access properties (never reassign whole object) |70| `readonly()` | State that must not be mutated outside owner |7172```typescript73// ✅ ref for primitives and replaceable objects74const count = ref(0);75const user = ref<User | null>(null);76user.value = fetchedUser;7778// ✅ reactive for objects79const form = reactive({ title: '', priority: 'medium' });8081// ❌ Never destructure reactive — reactivity lost82const { title } = form;83// ✅ Use toRefs84const { title } = toRefs(form);85```8687### Computed88891. **All derived state via `computed`** — never recompute in template:90 ```typescript91 const filteredTasks = computed(() =>92 tasks.value.filter(t => t.status === activeFilter.value)93 );94 ```95962. **No side effects in computed** — must be pure.97983. **Writable computed** — escape-hatch for complex two-way bindings only. For standard v-model, use `defineModel()` (see Component Design below):99 ```typescript100 // Only when defineModel() is insufficient (e.g., cross-store sync)101 const modelValue = computed({102 get: () => props.modelValue,103 set: (val) => emit('update:modelValue', val),104 });105 ```106107### Watch Strategy108109| Watcher | When |110|---|---|111| `watchEffect` | Re-run on any dependency change; auto-tracks |112| `watch` | Need old value, lazy execution, or explicit source |113| `computed` | Synchronous derived value (prefer over watch for transformation) |114115```typescript116// watchEffect — auto-tracks117watchEffect(() => {118 document.title = `Tasks (${count.value})`;119});120121// watch — explicit source122watch(userId, async (newId, oldId) => {123 if (newId !== oldId) await loadUser(newId);124}, { immediate: true });125```126127### Pinia Stores128129> Directory structure: `@.gemini/skills/project-structure-vue/SKILL.md`. Coding idioms below.1301311. **Setup Store API** (not Options):132 ```typescript133 export const useTaskStore = defineStore('task', () => {134 const tasks = ref<Task[]>([]);135 const isLoading = ref(false);136137 const completedTasks = computed(() =>138 tasks.value.filter(t => t.status === 'done')139 );140141 async function loadTasks() {142 isLoading.value = true;143 try {144 tasks.value = await taskAPI.getTasks();145 } finally {146 isLoading.value = false;147 }148 }149150 return { tasks, isLoading, completedTasks, loadTasks };151 });152 ```1531542. **Never mutate store state from outside** — call actions.1551563. **Inject API dependency** — never import directly inside store:157 ```typescript158 export const useTaskStore = defineStore('task', () => {159 const api = inject<TaskAPI>(TASK_API_KEY);160 if (!api) throw new Error('[TaskStore] TASK_API_KEY not provided — ensure app.provide() is called before store access');161 // ...162 });163 ```1641654. **`storeToRefs` for destructuring:**166 ```typescript167 const { tasks, isLoading } = storeToRefs(useTaskStore());168 const { loadTasks } = useTaskStore(); // actions don't need storeToRefs169 ```170171### Composables (`use*`)1721731. **Naming:** always prefix `use` — `useTaskFilters`, `useAuth`, `usePagination`.1741752. **Return reactive refs:**176 ```typescript177 function useCounter(initial = 0) {178 const count = ref(initial);179 function increment() { count.value++; }180 return { count, increment };181 }182 ```1831843. **Clean up side effects in `onUnmounted`:**185 ```typescript186 function useWindowResize() {187 const width = ref(window.innerWidth);188 const handler = () => (width.value = window.innerWidth);189 onMounted(() => window.addEventListener('resize', handler));190 onUnmounted(() => window.removeEventListener('resize', handler));191 return { width };192 }193 ```1941954. **Template refs with `useTemplateRef` (Vue 3.5+):**196 ```typescript197 const inputEl = useTemplateRef<HTMLInputElement>('myInput');198 // <input ref="myInput" />199 ```2002015. **Feature-specific composables inside feature dir.** Global composables in `src/composables/`.202203### Component Design2042051. **`defineProps` with TS generics:**206 ```typescript207 // Omit `const props =` if props are only used in template208 defineProps<{ title: string; count?: number }>();209210 // Use `const props =` only if props are accessed in <script setup>211 const props = defineProps<{ taskId: string; variant?: 'compact' | 'full' }>();212 ```2132142. **Prop defaults — destructuring (Vue 3.5+) or `withDefaults`:**215 ```typescript216 // ✅ Preferred: destructure with defaults (Vue 3.5+)217 const { title = 'Untitled', variant = 'full' } = defineProps<{218 title?: string;219 variant?: 'compact' | 'full';220 }>();221222 // ✅ Also valid: withDefaults223 const props = withDefaults(defineProps<{ variant?: 'compact' | 'full' }>(), {224 variant: 'full',225 });226 ```2272283. **`defineEmits` typed:**229 ```typescript230 const emit = defineEmits<{231 submit: [task: CreateTaskRequest];232 close: [];233 }>();234 ```2352364. **`defineModel()` for v-model (Vue 3.4+):**237 ```typescript238 // ✅ Simple two-way binding — replaces manual modelValue prop + emit239 const title = defineModel<string>();240241 // ✅ With options and modifiers242 const [title, modifiers] = defineModel<string>({243 default: 'default value',244 required: true,245 get: (value) => value.trim(),246 set: (value) => modifiers.capitalize247 ? value.charAt(0).toUpperCase() + value.slice(1) : value,248 });249250 // ✅ Multiple v-model bindings251 const firstName = defineModel<string>('firstName');252 const age = defineModel<number>('age');253 // Usage: <UserForm v-model:first-name="user.firstName" v-model:age="user.age" />254 ```2552565. **`defineExpose`** for selective parent access. Everything private by default.2572586. **`v-bind="$attrs"` + `inheritAttrs: false`** for attribute forwarding.2592607. **One concern per component.** Template over 100 lines -> extract sub-component.2612628. **No business logic in template** — computed/composables in `<script setup>`.263264### Template Conventions2652661. **`:key` with stable unique IDs in `v-for`** — never index when list order changes:267 ```html268 <TaskCard v-for="task in tasks" :key="task.id" :task="task" />269 ```2702712. **Never combine `v-if` and `v-for` on same element** — wrap with `<template>`.2722733. **Prop shorthand** — when value matches prop name:274 ```html275 <!-- ✅ Shorthand -->276 <MyComponent :count />277 <!-- ❌ Redundant -->278 <MyComponent :count="count" />279 ```2802814. **Slot shorthand** — `#` over `v-slot:`:282 ```html283 <!-- ✅ -->284 <template #header>...</template>285 <template #default>...</template>286 <!-- ❌ -->287 <template v-slot:header>...</template>288 ```2892905. **Explicit `<template>` tags for ALL used slots** — never rely on implicit default.2912926. **Case conventions:** camelCase in JS (props, emits), kebab-case in templates:293 ```html294 <!-- Template: kebab-case -->295 <UserCard :first-name="name" @update-profile="handleUpdate" />296 ```297 ```typescript298 // Script: camelCase299 defineProps<{ firstName: string }>();300 defineEmits<{ updateProfile: [] }>();301 ```3023037. **Component naming direction:** General → Specific — `SearchButtonClear.vue` not `ClearSearchButton.vue`. Mirrors natural language hierarchy.304305### Route Transitions306307CSS frameworks with `@layer` (Tailwind v4, Open Props, UnoCSS) can break SPA navigation by overriding transition properties. `transitionend` never fires -> entering component permanently blocked.3083091. **Avoid `mode="out-in"` with `@layer` frameworks.** Use simultaneous:310 ```html311 <!-- ✅ Safe -->312 <Transition name="fade">313 <component :is="Component" :key="$route.path" />314 </Transition>315 ```3163172. **Always `:key="$route.path"`** on dynamic component inside Transition.3183193. **`!important` on route transition CSS:**320 ```css321 .fade-enter-active {322 transition: opacity 0.15s ease-in !important;323 }324 .fade-leave-active {325 transition: opacity 0.15s ease-out !important;326 position: absolute !important;327 width: 100% !important;328 top: 0 !important;329 left: 0 !important;330 }331 .fade-enter-from,332 .fade-leave-to {333 opacity: 0 !important;334 }335 ```3363374. **Transition parent needs `position: relative`.**338339> Diagnosis: Debugging Protocol [Frontend module](file://.gemini/skills/debugging-protocol/languages/frontend.md) § CSS × Animation.340341### File-Based Routing342343Modern Vue projects use file-based routing where the file/folder structure defines routes. These conventions apply regardless of the specific tool (Unplugin Vue Router, Nuxt, etc.):3443451. **Avoid `index.vue`** — use route groups for descriptive names:346 ```347 src/pages/348 ├── (home).vue # Renders at / — descriptive name349 ├── about.vue # Renders at /about350 ├── [...path].vue # Catch-all (404)351 ├── users.vue # Layout for nested user routes352 └── users/353 ├── (user-list).vue # Renders at /users354 └── [userId].vue # Renders at /users/:userId355 ```3563572. **Named params over generic** — `[userId]` not `[id]`, `[postSlug]` not `[slug]`.3583593. **Dot notation for flat routes** — `users.edit.vue` → `/users/edit` without nesting.3603614. **Route groups for shared layouts** without affecting URL:362 ```363 src/pages/364 ├── (admin).vue # Layout for admin routes365 ├── (admin)/366 │ ├── dashboard.vue # /dashboard367 │ └── settings.vue # /settings368 ```3693705. **Typed route navigation** — prefer named route locations:371 ```typescript372 // ✅ Type-safe, refactor-safe373 router.push({ name: '/users/[userId]', params: { userId } });374 // ❌ String concatenation — fragile375 router.push('/users/' + userId);376 ```3773786. **`definePage()`** to customize route properties (meta, name, alias) inline.3793807. **Check `typed-router.d.ts`** for available route names and param types.381382### Testing383384> Naming/pyramid: GEMINI.md § Testing Strategy. Vue-specific below.3853861. **`createTestingPinia`** for component tests:387 ```typescript388 import { vi } from 'vitest';389 const wrapper = mount(TaskView, {390 global: {391 plugins: [createTestingPinia({ createSpy: vi.fn })],392 },393 });394 ```3953962. **Test behavior, not implementation** — query by accessible role, not CSS class.3973. **Test stores independently** — `setActivePinia(createPinia())`.398399### Linting and Type Checking400401| Tool | Purpose |402|---|---|403| `vue-tsc --noEmit` | Full-template type checking |404| `eslint-plugin-vue` | Vue-specific lint rules |405| `prettier` | Canonical formatting |406407See GEMINI.md § Code Completion Mandate for exact commands.408409### Related410- Code Idioms and Conventions GEMINI.md § Code Idioms and Conventions411- TypeScript Idioms and Patterns @.gemini/skills/typescript-idioms/SKILL.md412- Project Structure — Vue Frontend @.gemini/skills/project-structure-vue/SKILL.md413- Architectural Patterns GEMINI.md § Architectural Patterns414- Testing Strategy GEMINI.md § Testing Strategy415- Logging and Observability Principles @.gemini/skills/logging-and-observability-principles/SKILL.md