Loading...
Loading...
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.
npx skill4agent add affaan-m/ecc vue-patterns<script setup>.vuesrc/
├── api/ # API client and endpoint definitions
├── assets/ # Static assets (images, fonts, icons)
├── components/ # Shared/reusable components
│ ├── base/ # Base UI primitives (Button, Input, Modal)
│ └── features/ # Feature-specific shared components
├── composables/ # Reusable Composition API logic
├── layouts/ # Page layouts (optional)
├── pages/ # Route-level page components
├── router/ # Vue Router configuration
├── stores/ # Pinia stores
├── types/ # TypeScript type definitions
├── utils/ # Pure utility functions
└── App.vue # Root component| Convention | When to Use |
|---|---|
| All components (enforced by |
| Composables |
| Utilities, API clients, types |
| Route segments, feature folders |
<script setup lang="ts">
// 1. Imports (vue → ecosystem → absolute → relative)
// 2. Props & Emits & Slots
// 3. Composables
// 4. Local state (ref/reactive)
// 5. Computed properties
// 6. Methods
// 7. Watchers
// 8. Lifecycle hooks
</script>
<template>
<!-- Template content -->
</template>
<style scoped>
/* Scoped styles */
</style>// Type-based props with defaults
interface Props {
label: string;
variant?: "primary" | "secondary";
disabled?: boolean;
items: Item[];
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
disabled: false,
});typerequireddefaultisXxxhasXxxcanXxxdefineModel()modelValueupdate:modelValueconst emit = defineEmits<{
submit: [];
"update:modelValue": [value: string];
select: [id: string, index: number];
}>();@update:model-valueemit("update:modelValue", val)// composables/useDebounce.ts
export function useDebounce<T>(value: MaybeRef<T>, delay: number): Ref<T> {
const debounced = ref(toValue(value)) as Ref<T>;
let timer: ReturnType<typeof setTimeout>;
watch(
() => toValue(value),
(newVal) => {
clearTimeout(timer);
timer = setTimeout(() => { debounced.value = newVal; }, delay);
}
);
onUnmounted(() => clearTimeout(timer));
return readonly(debounced);
}userefcomputedreactiveMaybeReftoRef()toValue()onUnmountedonCleanup| Pattern | Use Case |
|---|---|
| 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 |
// stores/useCartStore.ts
export const useCartStore = defineStore("cart", () => {
const items = ref<CartItem[]>([]);
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 };
});$patch()const routes = [
{
path: "/users/:id",
name: "user-detail",
component: () => import("@/pages/UserDetail.vue"), // lazy
props: true, // pass params as props
meta: { requiresAuth: true },
},
];router.beforeEach((to, from) => {
const { isLoggedIn } = useAuthStore();
if (to.meta.requiresAuth && !isLoggedIn) {
return { name: "login", query: { redirect: to.fullPath } };
}
});const route = useRoute();
const id = computed(() => route.params.id as string);
watch(id, (newId) => fetchItem(newId));<!-- v-if/v-else-if/v-else -->
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else>{{ content }}</div>
<!-- v-show for frequent toggles -->
<div v-show="isOpen">Toggled content</div>
<!-- v-for with stable keys -->
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<!-- Computed filtered list (not v-if + v-for on same element) -->
<div v-for="item in activeItems" :key="item.id">{{ item.name }}</div>
<!-- Event handling -->
<form @submit.prevent="handleSubmit">
<button type="submit">Save</button>
</form>
<!-- v-model -->
<input v-model="name" />
<CustomInput v-model="value" v-model:title="title" />| Technique | When to Use |
|---|---|
| List items that rarely change |
| Content rendered once and static forever |
| Large data structures replaced wholesale |
| Only top-level properties are reactive |
| Frequent visibility toggles |
| Cache toggled views |
| Lazy routes | |
| Async component loading with fallback |
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"]);
});refcomputedwatchuseFetchuseAsyncDataconst { 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/api/users/[id].ts
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.string().uuid(),
}).parse);
// ... fetch and return
});// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// server-only
apiSecret: "",
// public (exposed to client)
public: {
apiBase: "https://api.example.com",
},
},
});defineProps()// 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) => { ... }); // PASS getter requireduseTemplateRef()useTemplateRef()import { useTemplateRef } from "vue";
const inputEl = useTemplateRef<HTMLInputElement>("input");
// "input" matches the ref="input" attribute in template, not the variable nameuseTemplateRef(dynamicRefId)onWatcherCleanup()import { watch, onWatcherCleanup } from "vue";
watch(userId, async (newId) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort());
// ... fetch with signal
});useId()import { useId } from "vue";
const id = useId();defer<Teleport defer><Teleport defer to="#container">Content</Teleport>
<div id="container"></div>defineAsyncComponent()hydrateimport { defineAsyncComponent, hydrateOnVisible } from "vue";
const AsyncComp = defineAsyncComponent({
loader: () => import("./Comp.vue"),
hydrate: hydrateOnVisible(),
});| Anti-Pattern | Why It's Wrong | The Fix |
|---|---|---|
Destructuring | Captures snapshot, loses reactivity | Access via |
| Compile-time error — destructured props can't be watched directly | Use getter wrapper: |
| Ambiguous execution order | Use computed filtered array |
| Broken state on reorder | Use stable database IDs |
| Mutating props | Violates one-way data flow | Emit events or use |
| 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 |
| Replacement breaks reactivity | Use |
| Watcher without cleanup | Memory leaks, race conditions | Use |
| Options API in new Vue 3 code | Ecosystem move to Composition API | Use |
| Plain ref for template references | No dynamic ref support, name-matching fragile | Use |
accessibilityfrontend-patternstypescriptcoding-standards