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:
typescript-idioms-and-patterns.md. Layout:project-structure-vue-frontend.md. Tests:testing-strategy.md. Logging:logging-and-observability-principles.md.
<script setup> — Only Style
Always <script setup lang="ts">. Never Options API or class-style for new code.
<!-- ✅ Canonical -->
<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 — not 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 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 for two-way bindings:
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:
project-structure-vue-frontend.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'); // ... });storeToRefsfor 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); const 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
definePropswith TS generics:const props = defineProps<{ taskId: string; variant?: 'compact' | 'full'; }>(); const props = withDefaults(defineProps<{ variant?: 'compact' | 'full' }>(), { variant: 'full', });defineEmitstyped:const emit = defineEmits<{ 'update:modelValue': [value: string]; 'submit': [task: CreateTaskRequest]; }>();v-model contract:
modelValueprop +update:modelValueemit.defineExposefor selective parent access. Everything private by default.v-bind="$attrs"+inheritAttrs: falsefor attribute forwarding.One concern per component. Template over 100 lines -> extract sub-component.
No business logic in template — computed/composables in
<script setup>.
Template Patterns
:keywith stable unique IDs inv-for— never index when list order changes:<TaskCard v-for="task in tasks" :key="task.id" :task="task" />Never combine
v-ifandv-foron same element — wrap with<template>.
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@layerframeworks. Use simultaneous:<!-- ✅ Safe --> <Transition name="fade"> <component :is="Component" :key="$route.path" /> </Transition>Always
:key="$route.path"on dynamic component inside Transition.!importanton 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.
Testing
Naming/pyramid:
testing-strategy.md. Vue-specific below.
createTestingPiniafor 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 code-completion-mandate.md for exact commands.
Related
- Code Idioms and Conventions @.gemini/rules/code-idioms-and-conventions.md
- TypeScript Idioms and Patterns @.gemini/rules/typescript-idioms-and-patterns.md
- Project Structure — Vue Frontend @.gemini/rules/project-structure-vue-frontend.md
- Architectural Patterns @.gemini/rules/architectural-pattern.md
- Testing Strategy @.gemini/rules/testing-strategy.md
- Logging and Observability Principles @.gemini/skills/logging-and-observability-principles/SKILL.md