Vue 3 patterns — Composition API, composables, reactivity, component design,
97
93%
Does it follow best practices?
Impact
99%
1.33xAverage score across 8 eval scenarios
Passed
No known issues
Patterns that prevent real bugs in Vue 3. Every section leads with WRONG vs RIGHT code.
<!-- RIGHT: ProductCard.vue -->
<script setup lang="ts">
interface Props {
product: Product;
}
const props = defineProps<Props>();
const emit = defineEmits<{ addToCart: [product: Product] }>();
</script>
<template>
<article class="product-card">
<h3>{{ product.name }}</h3>
<span>{{ product.price }}</span>
<button
@click="emit('addToCart', product)"
:aria-label="`Add ${product.name} to cart`"
>
Add to cart
</button>
</article>
</template>Rules:
<script setup lang="ts"> (never Options API, never omit lang="ts")defineProps<Props>() with a TS interface (not runtime defineProps({ prop: String }))defineEmits<{ eventName: [payloadType] }>() with object-and-tuple syntax.vue file:aria-label with descriptive textrole="alert" for screen readersComposables are the primary code-reuse mechanism. Getting the pattern wrong causes subtle bugs.
// RIGHT: composables/useOrders.ts
import { ref, onMounted } from 'vue';
export function useOrders() {
const orders = ref<Order[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
async function fetchOrders() {
loading.value = true;
error.value = null; // Clear stale error BEFORE try
try {
const res = await fetch('/api/orders');
if (!res.ok) throw new Error('Failed to load orders');
const data = await res.json();
orders.value = data.data;
} catch (err: any) {
error.value = err.message;
} finally {
loading.value = false; // Always in finally, not in try
}
}
onMounted(fetchOrders); // Auto-fetch on mount, NOT inline call
return { orders, loading, error, reload: fetchOrders };
}<!-- RIGHT: Usage in component -->
<script setup lang="ts">
const { orders, loading, error, reload } = useOrders();
</script>
<template>
<p v-if="loading">Loading orders...</p>
<div v-else-if="error" role="alert">
{{ error }}
<button @click="reload" :aria-label="'Retry loading orders'">Retry</button>
</div>
<OrderList v-else :orders="orders" />
</template>Critical composable rules:
useThing convention, file in composables/ directoryreload/retry function for consumersonMounted(fn) to trigger initial fetch (not a bare call at setup time)error.value = null before try)finally block, not inside try/catchv-if / v-else-if / v-else chain// WRONG: Destructuring breaks reactivity
const state = reactive({ count: 0, name: 'Vue' });
const { count, name } = state; // count and name are now plain values!
// RIGHT: Use ref() for values you'll destructure
const count = ref(0);
const name = ref('Vue');
// RIGHT: Or use toRefs() if you must destructure reactive
const { count, name } = toRefs(state);// WRONG: Breaks all existing references
let form = reactive({ name: '', email: '' });
form = reactive({ name: 'John', email: 'john@example.com' }); // Broken!
// RIGHT: Mutate properties or use Object.assign
Object.assign(form, { name: 'John', email: 'john@example.com' });// RIGHT
const count = ref(0); // Primitive -> ref
const loading = ref(false); // Primitive -> ref
const error = ref<string | null>(null); // Primitive/null -> ref
const items = ref<Item[]>([]); // Array -> ref (preferred)This is a common source of bugs: using the wrong one.
// computed: For DERIVED VALUES (pure, no side effects)
const fullName = computed(() => `${first.value} ${last.value}`);
const isAdmin = computed(() => user.value?.role === 'admin');
// watch: For SIDE EFFECTS triggered by specific state changes
// USE THIS for: redirects, notifications, API calls, localStorage writes
watch(isAuthenticated, (newVal, oldVal) => {
if (oldVal && !newVal) {
router.push('/login');
showToast('Session expired');
}
});
// watch with getter for nested property
watch(() => order.value?.status, (newStatus) => {
if (newStatus === 'ready') showNotification('Order ready!');
});
// watchEffect: For side effects that depend on MULTIPLE reactive sources
// (auto-tracks dependencies, runs immediately)
watchEffect(() => {
document.title = `${count.value} items in cart`;
});Key rule: Side effects (redirect, toast, localStorage, API call) triggered by state changes belong in watch(), NOT embedded inside action functions. This ensures they fire consistently regardless of how state changes.
// WRONG: Options Store syntax
export const useAuthStore = defineStore('auth', {
state: () => ({ user: null }),
getters: { isAdmin: (state) => state.user?.role === 'admin' },
actions: { login(data) { this.user = data; } }
});
// RIGHT: Setup Store syntax
import { ref, computed, watch } from 'vue';
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const isAuthenticated = ref(false);
// Derived values: use computed()
const userDisplayLabel = computed(() =>
user.value ? `${user.value.name} (${user.value.role})` : 'Guest'
);
// Actions: plain functions
function login(userData: User) {
user.value = userData;
isAuthenticated.value = true;
}
function logout() {
user.value = null;
isAuthenticated.value = false;
}
// Side effects: use watch(), NOT inside action functions
watch(isAuthenticated, (newVal, oldVal) => {
if (oldVal && !newVal) {
router.push('/login');
showToast('You have been logged out');
}
});
return { user, isAuthenticated, userDisplayLabel, login, logout };
});Rules:
stores/ directory (e.g., stores/auth.ts)defineStore('name', () => { ... }) setup function syntaxcomputed() for derived values inside the storewatch() for reactive side effects (redirects, notifications, persistence)ref() for component-only state (form fields, toggles)// WRONG: Direct destructure loses reactivity on state/getters
const { user, isAuthenticated, login } = useAuthStore();
// RIGHT: Use storeToRefs for state/getters, direct destructure for actions
import { storeToRefs } from 'pinia';
const authStore = useAuthStore();
const { user, isAuthenticated, userDisplayLabel } = storeToRefs(authStore);
const { login, logout } = authStore; // Actions don't need storeToRefs// RIGHT: Template ref pattern
import { ref, onMounted } from 'vue';
const inputEl = ref<HTMLInputElement | null>(null);
onMounted(() => {
inputEl.value?.focus();
});<template>
<input ref="inputEl" />
</template>The ref() variable name MUST match the template ref="..." attribute value.
<!-- Vue 3.4+ with defineModel (PREFERRED) -->
<script setup lang="ts">
const model = defineModel<string>();
</script>
<template>
<input :value="model" @input="model = ($event.target as HTMLInputElement).value" />
</template>
<!-- Parent usage -->
<MyInput v-model="searchQuery" /><!-- Pre-3.4 pattern (still valid) -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
</script>
<template>
<input :value="modelValue" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" />
</template>Every Vue component MUST follow these accessibility rules:
:aria-label with descriptive text, especially for icon buttons or buttons with generic text. Use dynamic labels: :aria-label="Add ${item.name} to cart"role="alert" to error display containers so screen readers announce errors:aria-label attributes (e.g., :aria-label="'Retry loading products'")<label :for="id"> or use aria-labelaria-busy="true" on containers being loaded<script setup lang="ts"> on every .vue filedefineProps<T>() (TS interface, not runtime syntax)defineEmits<{ event: [type] }>() (tuple syntax)composables/ dir, named useThing, return refs + reload fnonMounted() for initial data fetch (not bare call)finallyv-if/v-else-if/v-else chainref() for primitives; never destructure reactive() without toRefs()Object.assign() to resetdefineStore('name', () => {...}))computed() for derived values in stores; watch() for side effectswatch(), not in actionsstoreToRefs() when destructuring store state/gettersstores/ directoryref() for component-only state:aria-label on ALL interactive elements (buttons, links)role="alert" on error display containersref="name" matches const name = ref(null) variable