--- name: vue-idioms description: >- Vue 3 Composition API patterns: ` ``` --- ### Reactivity: `ref` vs `reactive` | Use | When | | ------------ | ---------------------------------------------------------------------------------- | | `ref()` | 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 | ```typescript // ✅ ref for primitives and replaceable objects const count = ref(0); const user = ref(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 1. **Use `computed` for all derived state** — never recompute in the template ```typescript // ✅ Cached, reactive const filteredTasks = computed(() => tasks.value.filter(t => t.status === activeFilter.value) ); // ❌ Recomputes on every render // ``` 2. **Never cause side effects inside `computed`** — computed must be pure ```typescript // ❌ Side effect in computed const count = computed(() => { taskStore.logAccess(); // NO — this is a side effect return tasks.value.length; }); ``` 3. **Use writable computed for two-way bindings** ```typescript 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) | ```typescript // ✅ 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. 1. **Use the Setup Store API** (not Options API) for new stores ```typescript // task/store/task.store.ts export const useTaskStore = defineStore('task', () => { // State const tasks = ref([]); 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 }; }); ``` 2. **Never mutate store state from outside the store** ```typescript // ❌ Direct mutation from a component const store = useTaskStore(); store.tasks.push(newTask); // NO // ✅ Call an action await store.addTask(newTask); ``` 3. **Inject the API dependency — never import it directly inside the store** ```typescript // ✅ Receives the API interface — testable with createTestingPinia + mock API export const useTaskStore = defineStore('task', () => { const api = inject(TASK_API_KEY); if (!api) throw new Error('[TaskStore] TASK_API_KEY not provided — ensure app.provide() is called before store access'); // ... }); ``` 4. **Use `storeToRefs` when destructuring a store in components** ```typescript // ✅ 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. 1. **Naming: always prefix with `use`** - `useTaskFilters`, `useAuth`, `usePagination` 2. **Return reactive refs, not raw values** ```typescript // ✅ 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 }; } ``` 3. **Always clean up side effects in `onUnmounted`** ```typescript 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 }; } ``` 4. **Template refs with `useTemplateRef` (Vue 3.5+)** — type-safe, IDE-friendly replacement for `ref(null)` ```typescript // ✅ Vue 3.5+ — useTemplateRef provides fully typed access const inputEl = useTemplateRef('myInput'); // // ❌ Old pattern (before 3.5) — less type-safe const inputEl = ref(null); ``` 5. **Feature-specific composables live inside the feature directory** — global composables go in `src/composables/`. See `references/project-structure.md`. --- ### Component Design 1. **`defineProps` with TypeScript generics — no runtime validators for typed props** ```typescript const props = defineProps<{ taskId: string; variant?: 'compact' | 'full'; }>(); // Defaults via withDefaults const props = withDefaults(defineProps<{ variant?: 'compact' | 'full' }>(), { variant: 'full', }); ``` 2. **`defineEmits` with typed event signatures** ```typescript const emit = defineEmits<{ 'update:modelValue': [value: string]; 'submit': [task: CreateTaskRequest]; }>(); ``` 3. **`defineModel` (Vue 3.4+) — preferred v-model pattern** ```vue ``` Pre-3.4 fallback (when `defineModel` is unavailable): ```typescript // ❌ Verbose — use defineModel instead on Vue 3.4+ const props = defineProps<{ modelValue: string }>(); const emit = defineEmits<{ 'update:modelValue': [value: string] }>(); ``` 4. **`defineExpose` to selectively expose methods to parent refs** ```typescript // Everything in ``` 3. **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.md` for methodology. This section covers Vue-specific patterns only. 1. **`defineAsyncComponent` for lazy loading heavy components:** ```typescript import { defineAsyncComponent } from 'vue'; const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue')); ``` 2. **Lazy route loading with Vue Router:** ```typescript const routes = [ { path: '/tasks', component: () => import('../views/TaskView.vue') }, { path: '/settings', component: () => import('../views/SettingsView.vue') }, ]; ``` 3. **`` for caching expensive component state:** ```html ``` 4. **`v-memo` for expensive list rendering (Vue 3.2+):** ```html
``` 5. **`v-once` for static content that never changes:** ```html
© 2026 Acme Corp
``` --- ### Testing > For test naming, pyramid ratios, and the AAA pattern, see `@.agents/rules/testing-strategy.md`. This section covers **Vue-specific tooling only**. 1. **Mount wrapper with `@vue/test-utils` + `createTestingPinia`:** ```typescript import { mount } from '@vue/test-utils'; import { createTestingPinia } from '@pinia/testing'; import { vi } from 'vitest'; function mountComponent(overrides: Record = {}) { return mount(TaskView, { global: { plugins: [createTestingPinia({ createSpy: vi.fn })], stubs: { RouterLink: true }, }, ...overrides, }); } ``` 2. **Component interaction — test behaviour, not implementation:** ```typescript 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' }), ); }); ``` 3. **Test composables in isolation:** ```typescript import { createApp } from 'vue'; /** Runs a composable inside a throwaway component context. */ function withSetup(composable: () => T): [T, ReturnType] { 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); }); ``` 4. **Test Pinia stores independently:** ```typescript import { setActivePinia, createPinia } from 'pinia'; beforeEach(() => { setActivePinia(createPinia()); }); test('loadTasks populates store', async () => { const store = useTaskStore(); await store.loadTasks(); expect(store.tasks).toHaveLength(3); }); ``` 5. **Snapshot testing for complex output:** ```typescript test('renders task card correctly', () => { const wrapper = mountComponent({ props: { task: mockTask } }); expect(wrapper.html()).toMatchSnapshot(); }); ``` --- ### Feedback Loop — Development Workflow > **Critical:** Use `vue-tsc --noEmit` instead of `tsc --noEmit` for Vue projects. > `tsc` cannot type-check `.vue` `