13 KiB
trigger, description
| trigger | description |
|---|---|
| model_decision | When writing Vue 3 code, working on Vue components, using Pinia stores, or building Vue composables |
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
typescript-idioms-and-patterns.md. For file and folder layout, seeproject-structure-vue-frontend.md. For test naming, seetesting-strategy.md. For logging, seelogging-and-observability-principles.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
project-structure-vue-frontend.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/. Seeproject-structure-vue-frontend.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]; }>(); -
v-modelcontract: alwaysmodelValueprop +update:modelValueemit -
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 § CSS × Animation.
Testing
Test naming and pyramid proportions are defined in
testing-strategy.md. This section covers Vue-specific tooling.
-
Use
createTestingPiniato stub stores in component testsimport { 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-completion-mandate.md for exact commands.
Related Principles
- Code Idioms and Conventions @code-idioms-and-conventions.md
- TypeScript Idioms and Patterns @typescript-idioms-and-patterns.md
- Project Structure — Vue Frontend @project-structure-vue-frontend.md
- Architectural Patterns — Testability-First Design @architectural-pattern.md
- Testing Strategy @testing-strategy.md
- Logging and Observability Principles @logging-and-observability-principles.md