Vue Composition and Patterns
Help agents implement logic with script setup, composables, and Pinia conventions, and handle ref, reactive, and template refs correctly.
The SKILL should define composable naming (useXxx), return shapes (readonly refs, grouped methods), and contracts with component props/emits; align defineProps/defineEmits and TypeScript macros with project tsconfig.
Document when to use watch vs watchEffect, cleaning up side effects (onScopeDispose), and pitfalls from unwrapping large objects in templates; route and async-component loading errors should be testable.
If relevant, call out Vite/Nuxt auto-imports and SSR constraints such as onMounted limitations in procedural steps.
Composable Design and Implementation
useCounter: minimal viable composable demonstrating ref, readonly exposure, and method grouping.
// composables/useCounter.ts
import { ref, readonly } from 'vue'
export function useCounter(initial = 0) {
const count = ref(initial)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initial }
return {
count: readonly(count), // prevent external direct assignment
increment,
decrement,
reset,
}
}
// Usage
// <script setup lang="ts">
// const { count, increment } = useCounter(10)
// </script>
useAuth: async composable with loading state and side effect cleanup.
// composables/useAuth.ts
import { ref, onScopeDispose } from 'vue'
import type { User } from '@/types'
export function useAuth() {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
let abortCtrl: AbortController | null = null
async function fetchUser() {
abortCtrl = new AbortController()
loading.value = true
error.value = null
try {
const res = await fetch('/api/me', { signal: abortCtrl.signal })
if (!res.ok) throw new Error(`${res.status}`)
user.value = await res.json()
} catch (e: unknown) {
if ((e as Error).name !== 'AbortError')
error.value = (e as Error).message
} finally {
loading.value = false
}
}
// Cancel in-flight request when the component unmounts
onScopeDispose(() => abortCtrl?.abort())
return { user, loading, error, fetchUser }
}
data with Composition API ref in the same component; migrate at file granularity and backfill types and tests.
Teleport and Suspense Usage
Teleport: mount modals or Toasts directly on body to resolve stacking context issues.
<!-- components/ConfirmDialog.vue -->
<script setup lang="ts">
defineProps<{ open: boolean; title: string }>()
const emit = defineEmits<{ confirm: []; cancel: [] }>()
</script>
<template>
<Teleport to="body">
<div v-if="open" class="dialog-backdrop" @click.self="emit('cancel')">
<div class="dialog" role="dialog" :aria-modal="true">
<h2>{{ title }}</h2>
<slot />
<div class="dialog-actions">
<button @click="emit('cancel')">Cancel</button>
<button @click="emit('confirm')">Confirm</button>
</div>
</div>
</div>
</Teleport>
</template>
Suspense: paired with async components for unified loading and error boundaries.
<!-- views/DashboardView.vue -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
loadingComponent: () => h('div', 'Loading...'),
errorComponent: () => h('div', 'Load failed'),
delay: 200,
timeout: 8000,
})
</script>
<template>
<Suspense>
<template #default>
<HeavyChart :data="chartData" />
</template>
<template #fallback>
<Skeleton height="320px" />
</template>
</Suspense>
</template>
For scoped slots delegating data and rendering to parents, document slot prop shapes in the SKILL; pair v-model with modelValue / update:modelValue or custom update:xxx events in the same place.
Pinia State Management and provide/inject
Pinia store definition (Setup Store style, consistent with composable syntax):
// stores/useUserStore.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { User } from '@/types'
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const isLoggedIn = computed(() => !!token.value)
const displayName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : 'Guest'
)
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' },
})
const data = await res.json()
token.value = data.token
user.value = data.user
}
function logout() {
user.value = null
token.value = null
}
return { user, token, isLoggedIn, displayName, login, logout }
})
storeToRefs destructuring (preserves reactivity):
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/useUserStore'
const store = useUserStore()
// ✅ Reactivity preserved: use storeToRefs for state/computed
const { user, isLoggedIn, displayName } = storeToRefs(store)
// ✅ Actions destructured directly (non-reactive, no storeToRefs needed)
const { login, logout } = store
</script>
Type-safe provide/inject wrapper (InjectionKey pattern):
// composables/useTheme.ts — provide side
import { provide, inject, ref, type InjectionKey } from 'vue'
export interface ThemeContext {
theme: Ref<'light' | 'dark'>
toggle: () => void
}
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol('theme')
export function provideTheme() {
const theme = ref<'light' | 'dark'>('light')
const toggle = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' }
provide(THEME_KEY, { theme, toggle })
}
// inject side (with default value to guarantee type safety)
export function useTheme(): ThemeContext {
const ctx = inject(THEME_KEY)
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider')
return ctx
}
- Pinia actions must not directly manipulate the DOM; isolate side effects in composables then inject.
- Use provide/inject to pass small contexts cross-layer (theme, locale, permissions); use Pinia for large shared state.
- Testing: Vue Test Utils
mount+createTestingPinia()to override initial store state.
On-page tools: props/emits checklist and slot name formatting
The scripts below run only on this page—use them to capture contract bullets for a SKILL or to align named slot syntax in templates.
Props / emits mini checklist
Check items you have implemented, then generate a summary you can paste into prompts or doc drafts.
Named slot id → template kebab-case
Enter camelCase, PascalCase, or snake_case; get named-slot syntax for SFC templates (e.g. #side-panel, v-slot:side-panel).
SKILL outline
---
name: vue-composition-patterns
description: Implement features with script setup and composables per Vue 3 conventions
---
# Rules
- All new components use <script setup lang="ts">; never mix Options API in the same file
- Composables are named useXxx; return readonly refs + grouped methods; clean up side effects with onScopeDispose
- Pinia stores use Setup Store style; destructure state/computed with storeToRefs inside components
- provide/inject must use InjectionKey<T>; always provide a runtime guard on the inject side
# Steps
1. Read existing composables/ directory to confirm useXxx naming and return-value contracts
2. Props/Emits: defineProps<T>() + defineEmits<{ evt: [payload] }>()
3. Reactivity: prefer ref; specify precise sources in watch; use watchEffect for DOM side effects
4. Pinia: defineStore in stores/ directory; wrap actions in try/catch for unified error handling
5. provide/inject: use when crossing 2+ component layers; single-layer prop drilling still uses props
6. Teleport: mount modals/toasts on body; Suspense: async component + errorComponent + timeout
7. Testing: test composables with withSetup or mount; test stores with createTestingPinia()