---
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
// {{ tasks.filter(t => t.status === filter) }}
```
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
```
---
### 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` `` 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 --noEmit` on Vue projects — it skips all `.vue` template checking.
- `eslint-plugin-vue` must be configured with `plugin:vue/vue3-recommended` or stricter.
- `prettier` must handle `.vue` files (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 `