| name | vue-idioms |
| description | Vue 3 Composition API, Pinia stores, composables, Vite, Vitest. |
| paths | ["**/*.vue","**/vite.config.*","**/vitest.config.*","**/store/**/*.ts","**/stores/**/*.ts","**/*.store.ts","**/pinia*.ts"] |
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. For test naming, see testing-strategy.md. For logging, see @.agents/skills/logging-implementation/SKILL.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 |
const count = ref(0);
const user = ref<User | null>(null);
user.value = fetchedUser;
const form = reactive({ title: '', priority: 'medium' });
const { title } = form;
const { title } = toRefs(form);
Computed Properties
-
Use computed for all derived state — never recompute in the template
const filteredTasks = computed(() =>
tasks.value.filter(t => t.status === activeFilter.value)
);
-
Never cause side effects inside computed — computed must be pure
const count = computed(() => {
taskStore.logAccess();
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(() => {
document.title = `Tasks (${count.value})`;
});
watch(userId, async (newId, oldId) => {
if (newId !== oldId) await loadUser(newId);
}, { immediate: true });
watch(tasks, () => { filteredCount.value = tasks.value.filter(...).length; });
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
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 the store
const store = useTaskStore();
store.tasks.push(newTask);
await store.addTask(newTask);
-
Inject the API dependency — never import it directly inside the 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');
});
-
Use storeToRefs when destructuring a store in components
const { tasks, isLoading } = storeToRefs(useTaskStore());
const { loadTasks } = useTaskStore();
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
function useCounter(initial = 0) {
const count = ref(initial);
const increment = () => count.value++;
return { count, increment };
}
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));
return { width };
}
-
Template refs with useTemplateRef (Vue 3.5+) — type-safe, IDE-friendly replacement for ref(null)
const inputEl = useTemplateRef<HTMLInputElement>('myInput');
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';
}>();
const props = withDefaults(defineProps<{ variant?: 'compact' | 'full' }>(), {
variant: 'full',
});
-
defineEmits with typed event signatures
const emit = defineEmits<{
'update:modelValue': [value: string];
'submit': [task: CreateTaskRequest];
}>();
-
v-model contract: always modelValue prop + update:modelValue emit
-
defineExpose to selectively expose methods to parent refs
defineExpose({ reset, focus });
-
v-bind="$attrs" and inheritAttrs: false for forwarding attributes
defineOptions({ inheritAttrs: false });
-
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
<TaskCard v-for="task in tasks" :key="task.id" :task="task" />
<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:
<Transition name="fade" mode="out-in">
<component :is="Component" />
</Transition>
<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.
Testing
Test naming and pyramid proportions are defined in testing-strategy.md. This section covers Vue-specific tooling.
-
Use createTestingPinia to stub stores in component tests
import { vi } from 'vitest';
const wrapper = mount(TaskView, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
},
});
-
Test component behaviour, not implementation details — query by accessible role, not CSS class
-
Test stores independently — use setActivePinia(createPinia()) in store unit tests
Linting and Type Checking
| Tool | Purpose |
|---|
vue-tsc --noEmit | Full-template type checking |
eslint-plugin-vue | Vue-specific lint rules |
prettier | Canonical formatting |
See code-idioms-and-conventions.md for exact commands.
Related Principles
- Code Idioms and Conventions @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
- Security Principles @.agents/rules/security-principles.md
- Accessibility Principles @.agents/rules/accessibility-principles.md
- Architectural Patterns — Testability-First Design @architectural-pattern.md
- Testing Strategy @testing-strategy.md
- Error Handling Principles @error-handling-principles.md
- Logging and Observability Principles @.agents/skills/logging-implementation/SKILL.md