--- name: vue-patterns description: Vue.js 3 Composition API patterns, component architecture, reactivity best practices, Pinia state management, Vue Router navigation, and Nuxt SSR patterns. Activates for Vue, Nuxt, Vite, or Pinia projects. origin: ECC --- # Vue.js Patterns and Best Practices Comprehensive guide for Vue.js 3 development using Composition API (` ``` ### Presentational vs Container - **Container components**: Own data fetching, state, and side effects. Render presentational components. - **Presentational components**: Receive props, emit events. No API calls, no store access. Pure rendering. ### Props Best Practices ```ts // Type-based props with defaults interface Props { label: string; variant?: "primary" | "secondary"; disabled?: boolean; items: Item[]; } const props = withDefaults(defineProps(), { variant: "primary", disabled: false, }); ``` - Always provide `type`, and `required`/`default` where appropriate. - Boolean props: `isXxx`, `hasXxx`, `canXxx`. - Never mutate props — emit events instead. - For v-model binding, use `defineModel()` (Vue 3.4+) or `modelValue` + `update:modelValue`. ### Events ```ts const emit = defineEmits<{ submit: []; "update:modelValue": [value: string]; select: [id: string, index: number]; }>(); ``` - Use kebab-case in templates (`@update:model-value`). - Use camelCase in script (`emit("update:modelValue", val)`). --- ## 3. Composables (Reusable Logic) ### Structure ```ts // composables/useDebounce.ts export function useDebounce(value: MaybeRef, delay: number): Ref { const debounced = ref(toValue(value)) as Ref; let timer: ReturnType; watch( () => toValue(value), (newVal) => { clearTimeout(timer); timer = setTimeout(() => { debounced.value = newVal; }, delay); } ); onUnmounted(() => clearTimeout(timer)); return readonly(debounced); } ``` ### Rules - Must start with `use` prefix. - Return reactive values (`ref`, `computed`, `reactive`), never plain primitives. - Accept reactive inputs via `MaybeRef` / `toRef()` / `toValue()`. - Clean up side effects in `onUnmounted` or watcher `onCleanup`. - No module-scope side effects. ### vs Mixins Composables replace Vue 2 mixins entirely: - **Mixins**: Opaque data flow, source-of-truth collisions, name conflicts. - **Composables**: Explicit imports, clear return values, composable and tree-shakable. --- ## 4. State Management ### When to Use What | Pattern | Use Case | |---------|----------| | `ref()` / `reactive()` | Local component state | | Props + Emits | Parent-child communication | | Provide / Inject | Theme, config, plugin API | | Pinia store | Global, shared, complex state | | Server state composable | API data with caching (wrap `fetch`/TanStack Query) | ### Pinia Setup Store (Preferred) ```ts // stores/useCartStore.ts export const useCartStore = defineStore("cart", () => { const items = ref([]); const isLoading = ref(false); const totalPrice = computed(() => items.value.reduce((sum, i) => sum + i.price * i.quantity, 0) ); const itemCount = computed(() => items.value.reduce((sum, i) => sum + i.quantity, 0) ); async function addItem(productId: string) { isLoading.value = true; try { const item = await fetchProduct(productId); const existing = items.value.find(i => i.id === item.id); if (existing) existing.quantity++; else items.value.push({ ...item, quantity: 1 }); } finally { isLoading.value = false; } } return { items, isLoading, totalPrice, itemCount, addItem }; }); ``` - Use Setup Store syntax (not Options Store). - Actions: only place to mutate state. - Every async action: handle loading + success + error. --- ## 5. Vue Router ### Route Definitions ```ts const routes = [ { path: "/users/:id", name: "user-detail", component: () => import("@/pages/UserDetail.vue"), // lazy props: true, // pass params as props meta: { requiresAuth: true }, }, ]; ``` ### Navigation Guards ```ts router.beforeEach((to, from) => { const { isLoggedIn } = useAuthStore(); if (to.meta.requiresAuth && !isLoggedIn) { return { name: "login", query: { redirect: to.fullPath } }; } }); ``` ### Reactive Route Params When a component stays mounted but route params change: ```ts const route = useRoute(); const id = computed(() => route.params.id as string); watch(id, (newId) => fetchItem(newId)); ``` --- ## 6. Template Patterns ### Template Syntax ```vue
Loading...
Error: {{ error }}
{{ content }}
Toggled content
{{ item.name }}
{{ item.name }}
``` --- ## 7. Performance | Technique | When to Use | |-----------|-------------| | `v-memo` | List items that rarely change | | `v-once` | Content rendered once and static forever | | `shallowRef()` | Large data structures replaced wholesale | | `shallowReactive()` | Only top-level properties are reactive | | `v-show` over `v-if` | Frequent visibility toggles | | `` | Cache toggled views | | Lazy routes | `() => import(...)` for non-critical routes | | `Suspense` | Async component loading with fallback | --- ## 8. Testing ### Stack - **Vitest** for unit and component tests - **Vue Test Utils** for mounting and interaction - **@pinia/testing** for store mocking - **Playwright** for E2E ### Component Test Pattern ```ts import { mount } from "@vue/test-utils"; import { createPinia, setActivePinia } from "pinia"; import UserCard from "./UserCard.vue"; beforeEach(() => { setActivePinia(createPinia()); }); it("renders and emits", async () => { const wrapper = mount(UserCard, { props: { user: { id: "1", name: "Alice" } }, }); expect(wrapper.text()).toContain("Alice"); await wrapper.find("button").trigger("click"); expect(wrapper.emitted("select")![0]).toEqual(["1"]); }); ``` --- ## 9. Nuxt-Specific Patterns ### Auto-Imports Nuxt auto-imports `ref`, `computed`, `watch`, `useFetch`, `useAsyncData`, etc. Use them directly without importing. For non-Nuxt projects, always import explicitly. ### useAsyncData / useFetch ```ts const { data: user, pending, error, refresh } = await useAsyncData( "user", // unique key for caching () => $fetch(`/api/users/${id}`), ); const { data: posts } = await useFetch("/api/posts", { query: { page: 1 }, key: "posts-page-1", // dedupes requests }); ``` ### Server Routes ```ts // server/api/users/[id].ts export default defineEventHandler(async (event) => { const { id } = await getValidatedRouterParams(event, z.object({ id: z.string().uuid(), }).parse); // ... fetch and return }); ``` ### Runtime Config ```ts // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { // server-only apiSecret: "", // public (exposed to client) public: { apiBase: "https://api.example.com", }, }, }); ``` --- ## 10. Vue 3.5+ New APIs ### Reactive Props Destructure Vue 3.5 stabilized reactive props destructure — destructured variables from `defineProps()` are automatically reactive: ```ts // Vue 3.5+: destructured props are reactive (no need for toRefs) const { count = 0, msg = "hello" } = defineProps<{ count?: number; msg?: string; }>(); // Limitation: cannot watch destructured prop directly watch(() => count, (newVal) => { ... }); // ✅ getter required ``` ### `useTemplateRef()` Replace name-matched plain refs with `useTemplateRef()` for template references: ```ts import { useTemplateRef } from "vue"; const inputEl = useTemplateRef("input"); // "input" matches the ref="input" attribute in template, not the variable name ``` Supports dynamic ref IDs: `useTemplateRef(dynamicRefId)`. ### `onWatcherCleanup()` Globally importable watcher cleanup API (Vue 3.5+): ```ts import { watch, onWatcherCleanup } from "vue"; watch(userId, async (newId) => { const controller = new AbortController(); onWatcherCleanup(() => controller.abort()); // ... fetch with signal }); ``` ### `useId()` SSR-stable unique ID generation for form elements and accessibility: ```ts import { useId } from "vue"; const id = useId(); ``` ### `defer` Teleport `` allows teleporting to targets rendered in the same cycle: ```vue Content
``` ### Lazy Hydration (SSR) `defineAsyncComponent()` now supports `hydrate` strategy: ```ts import { defineAsyncComponent, hydrateOnVisible } from "vue"; const AsyncComp = defineAsyncComponent({ loader: () => import("./Comp.vue"), hydrate: hydrateOnVisible(), }); ``` --- ## Anti-Patterns | Anti-Pattern | Why It's Wrong | The Fix | |-------------|---------------|---------| | Destructuring `defineProps()` (Vue < 3.5) | Captures snapshot, loses reactivity | Access via `props.xxx` or use `toRefs()` | | `watch()` on destructured prop (Vue 3.5+) | Compile-time error — destructured props can't be watched directly | Use getter wrapper: `watch(() => count, ...)` | | `v-if` + `v-for` on same element | Ambiguous execution order | Use computed filtered array | | `v-for` key = index | Broken state on reorder | Use stable database IDs | | Mutating props | Violates one-way data flow | Emit events or use `v-model` | | `v-html` with user content | XSS vulnerability | Sanitize with DOMPurify | | Mixins in Vue 3 | Opaque, collision-prone | Replace with composables | | Module-scope side effects in composable | Shared across instances | Scope in `onMounted` + `onUnmounted` | | `reactive()` for replaceable state | Replacement breaks reactivity | Use `ref()` instead | | Watcher without cleanup | Memory leaks, race conditions | Use `onCleanup` or `onWatcherCleanup()` (Vue 3.5+) | | Options API in new Vue 3 code | Ecosystem move to Composition API | Use `