with one click
vue-idioms
Vue 3 Composition API, Pinia stores, composables, Vite, Vitest.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
Vue 3 Composition API, Pinia stores, composables, Vite, Vitest.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Structured fault tolerance for coordinator agents. 5-level escalation ladder (Retry → Replace → Skip → Redistribute → Degrade), dead-man timers, degraded completion protocol, and cross-level escalation format. Load when orchestrating agents that may fail.
Structured code review protocol for inspecting code quality against the full rule set. Use when auditing code written by yourself or another agent, during the /audit workflow, or when the user asks for a code review.
Reusable convergence protocol for coordinator agents. Defines the BRIEFING → ITERATE → GATE → CONVERGE loop, context hygiene rules, self-succession protocol, turn budget, and handoff compression. Load when orchestrating multi-iteration workflows.
Pre-flight checklist and post-implementation self-review protocol. Use before generating any code (pre-flight) and after writing code but before verification (self-review) to catch issues early.
MECE task decomposition, file ownership enforcement, DAG-based execution, and safe merge protocol for intra-domain parallel dispatch. The safety invariants that prevent merge chaos when multiple agents write in parallel. Applies recursively at every nesting depth.
Shared protocols for all agents in the multi-agent pipeline: recursive nesting, pre-implementation restatement, parallel dispatch format, and agent definition cascade. Load this skill instead of inlining these protocols in every agent file.
| 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 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. For test naming, seetesting-strategy.md. For logging, see@.agents/skills/logging-implementation/SKILL.md.
<script setup> — The Only StyleAlways 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>
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);
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),
});
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);
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
use* Functions)Composables are the Vue equivalent of custom hooks — self-contained, reusable units of reactive logic.
Naming: always prefix with use
useTaskFilters, useAuth, usePaginationReturn 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.
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];
}>();
v-model contract: always modelValue prop + update:modelValue emit
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>
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>
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.
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
| 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.