vue-idioms
SkillDev toolsVue 3 Composition API, Pinia stores, composables, Vite, Vitest.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the vue-idioms skill
What this skill tells your AI
The instructions your AI receives, as published by irahardianto/awesome-agv in .agents/skills/vue-idioms/SKILL.md and read by ahel’s review.
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, seereferences/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 withtypescript-idioms(required for any Vue work).
When to Load References
Always load
typescript-idiomsfirst — 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), improveduseId, Suspense stable - 3.4+ —
defineModel(replaces verbose v-model boilerplate), improvedwatchgenerics - 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
computedfor 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
storeToRefswhen 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
useuseTaskFilters,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
onUnmountedfunction 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 forref(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/. Seereferences/project-structure.md.
Component Design
-
definePropswith TypeScript generics — no runtime validators for typed propsconst props = defineProps<{ taskId: string; variant?: 'compact' | 'full'; }>(); // Defaults via withDefaults const props = withDefaults(defineProps<{ variant?: 'compact' | 'full' }>(), { variant: 'full', }); -
defineEmitswith typed event signaturesconst 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
defineModelis unavailable):// ❌ Verbose — use defineModel instead on Vue 3.4+ const props = defineProps<{ modelValue: string }>(); const emit = defineEmits<{ 'update:modelValue': [value: string] }>(); -
defineExposeto 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"andinheritAttrs: falsefor 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
:keywith stable, unique IDs inv-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-ifandv-foron 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'stransitionendevent 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
!importanton route transition CSS classes — guarantees transition properties win the@layercascade:.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.
-
defineModelfor 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 onSubmit = handleSubmit(async (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.mdfor methodology. This section covers Vue-specific patterns only.
-
defineAsyncComponentfor 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-memofor 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-oncefor 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 --noEmitinstead oftsc --noEmitfor Vue projects.tsccannot 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 --noEmiton Vue projects — it skips all.vuetemplate checking. eslint-plugin-vuemust be configured withplugin:vue/vue3-recommendedor stricter.prettiermust handle.vuefiles (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()orstoreToRefs() - ❌ Side effects in
computed— computed must be pure; usewatchorwatchEffect - ❌
v-if+v-foron 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 tocomputedor composables - ❌
tsc --noEmiton Vue projects — usevue-tsc --noEmit(template checking) - ❌
ref(null)for template refs in Vue 3.5+ — useuseTemplateRef()instead - ❌ Verbose
modelValue+ emit in Vue 3.4+ — usedefineModel()instead - ❌ Importing API clients directly in stores — inject via
inject()for testability - ❌
watchfor derived state — usecomputedinstead (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
Signals
- GitHub stars
- 156
- Forks
- 53
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
vue-idioms- Source
- github.com/irahardianto/awesome-agv