smithery.ai

vuejs-patterns

Vue.js 3 Composition API patterns and best practices. Use when creating Vue components, composables, or any reactive frontend code with Vue.js.

First seen Mar 25, 2026

Installation

$ npx skills add https://smithery.ai

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery.ai · top by installs.

npx skills add https://smithery.ai

Browse all from smithery.ai

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Skill metadata

Parsed from SKILL.md frontmatter.

Allowed toolsRead, Grep, Glob, Edit, Write

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 16,594 B
  • docs SUMMARY.md 165 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 1 installs

SKILL.md

Vue.js 3 Patterns Skill

This skill provides guidance for writing clean, maintainable Vue.js 3 code using the Composition API.

Component Structure

Single File Component (SFC) Template

<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useEmployeeStore } from '@/stores/employee'
import type { Employee } from '@/types'

// Props
interface Props {
  employeeId: string
  readonly?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  readonly: false
})

// Emits
const emit = defineEmits<{
  save: [employee: Employee]
  cancel: []
}>()

// Stores
const employeeStore = useEmployeeStore()

// Reactive State
const isLoading = ref(false)
const employee = ref<Employee | null>(null)
const formData = ref({
  name: '',
  email: '',
  department: ''
})

// Computed
const isValid = computed(() => {
  return formData.value.name.length > 0 && formData.value.email.includes('@')
})

const fullName = computed(() => {
  return `${employee.value?.firstName} ${employee.value?.lastName}`
})

// Methods
async function loadEmployee() {
  isLoading.value = true
  try {
    employee.value = await employeeStore.fetchById(props.employeeId)
    formData.value = { ...employee.value }
  } catch (error) {
    console.error('Failed to load employee:', error)
  } finally {
    isLoading.value = false
  }
}

function handleSubmit() {
  if (!isValid.value) return
  emit('save', formData.value as Employee)
}

// Watchers
watch(() => props.employeeId, (newId) => {
  if (newId) loadEmployee()
}, { immediate: true })

// Lifecycle
onMounted(() => {
  loadEmployee()
})

// Expose to parent (if needed)
defineExpose({
  refresh: loadEmployee
})
</script>

<template>
  <div class="employee-form">
    <div v-if="isLoading" class="loading-state">
      <LoadingSpinner />
    </div>

    <form v-else @submit.prevent="handleSubmit">
      <FormInput
        v-model="formData.name"
        label="Name"
        :disabled="readonly"
        required
      />

      <FormInput
        v-model="formData.email"
        type="email"
        label="Email"
        :disabled="readonly"
        required
      />

      <div class="form-actions">
        <BaseButton type="button" variant="secondary" @click="emit('cancel')">
          Cancel
        </BaseButton>
        <BaseButton type="submit" :disabled="!isValid || readonly">
          Save
        </BaseButton>
      </div>
    </form>
  </div>
</template>

<style scoped>
.employee-form {
  @apply max-w-lg mx-auto p-6;
}

.form-actions {
  @apply flex justify-end gap-3 mt-6;
}
</style>

Composables (Reusable Logic)

useAsync Composable

// composables/useAsync.ts
import { ref, type Ref } from 'vue'

interface UseAsyncReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  isLoading: Ref<boolean>
  execute: (...args: any[]) => Promise<T | null>
}

export function useAsync<T>(
  asyncFn: (...args: any[]) => Promise<T>
): UseAsyncReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  async function execute(...args: any[]): Promise<T | null> {
    isLoading.value = true
    error.value = null

    try {
      data.value = await asyncFn(...args)
      return data.value
    } catch (e) {
      error.value = e as Error
      return null
    } finally {
      isLoading.value = false
    }
  }

  return { data, error, isLoading, execute }
}

// Usage
const { data: employees, isLoading, execute: fetchEmployees } = useAsync(
  () => api.getEmployees()
)

usePagination Composable

// composables/usePagination.ts
import { ref, computed, watch } from 'vue'

interface PaginationOptions {
  initialPage?: number
  initialPerPage?: number
  total?: number
}

export function usePagination(options: PaginationOptions = {}) {
  const currentPage = ref(options.initialPage ?? 1)
  const perPage = ref(options.initialPerPage ?? 15)
  const total = ref(options.total ?? 0)

  const totalPages = computed(() => Math.ceil(total.value / perPage.value))
  const hasNextPage = computed(() => currentPage.value < totalPages.value)
  const hasPrevPage = computed(() => currentPage.value > 1)

  const offset = computed(() => (currentPage.value - 1) * perPage.value)

  function nextPage() {
    if (hasNextPage.value) currentPage.value++
  }

  function prevPage() {
    if (hasPrevPage.value) currentPage.value--
  }

  function goToPage(page: number) {
    if (page >= 1 && page <= totalPages.value) {
      currentPage.value = page
    }
  }

  function setTotal(newTotal: number) {
    total.value = newTotal
  }

  return {
    currentPage,
    perPage,
    total,
    totalPages,
    hasNextPage,
    hasPrevPage,
    offset,
    nextPage,
    prevPage,
    goToPage,
    setTotal
  }
}

useDebounce Composable

// composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'

export function useDebounce<T>(value: Ref<T>, delay: number = 300): Ref<T> {
  const debouncedValue = ref(value.value) as Ref<T>
  let timeoutId: ReturnType<typeof setTimeout>

  watch(value, (newValue) => {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  return debouncedValue
}

// Usage
const searchQuery = ref('')
const debouncedSearch = useDebounce(searchQuery, 500)

watch(debouncedSearch, (query) => {
  fetchResults(query)
})

useLocalStorage Composable

// composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
  const stored = localStorage.getItem(key)
  const data = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T>

  watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })

  return data
}

// Usage
const theme = useLocalStorage('theme', 'light')
const userPreferences = useLocalStorage('preferences', { notifications: true })

State Management with Pinia

Store Definition

// stores/employee.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Employee } from '@/types'
import { employeeApi } from '@/api'

export const useEmployeeStore = defineStore('employee', () => {
  // State
  const employees = ref<Employee[]>([])
  const currentEmployee = ref<Employee | null>(null)
  const isLoading = ref(false)
  const error = ref<string | null>(null)

  // Getters
  const activeEmployees = computed(() =>
    employees.value.filter(e => e.status === 'active')
  )

  const employeeCount = computed(() => employees.value.length)

  const getById = computed(() => {
    return (id: string) => employees.value.find(e => e.id === id)
  })

  // Actions
  async function fetchAll() {
    isLoading.value = true
    error.value = null
    try {
      employees.value = await employeeApi.getAll()
    } catch (e) {
      error.value = (e as Error).message
    } finally {
      isLoading.value = false
    }
  }

  async function fetchById(id: string) {
    isLoading.value = true
    try {
      currentEmployee.value = await employeeApi.getById(id)
      return currentEmployee.value
    } catch (e) {
      error.value = (e as Error).message
      return null
    } finally {
      isLoading.value = false
    }
  }

  async function create(data: Partial<Employee>) {
    const newEmployee = await employeeApi.create(data)
    employees.value.push(newEmployee)
    return newEmployee
  }

  async function update(id: string, data: Partial<Employee>) {
    const updated = await employeeApi.update(id, data)
    const index = employees.value.findIndex(e => e.id === id)
    if (index !== -1) {
      employees.value[index] = updated
    }
    return updated
  }

  async function remove(id: string) {
    await employeeApi.delete(id)
    employees.value = employees.value.filter(e => e.id !== id)
  }

  function $reset() {
    employees.value = []
    currentEmployee.value = null
    isLoading.value = false
    error.value = null
  }

  return {
    // State
    employees,
    currentEmployee,
    isLoading,
    error,
    // Getters
    activeEmployees,
    employeeCount,
    getById,
    // Actions
    fetchAll,
    fetchById,
    create,
    update,
    remove,
    $reset
  }
})

Component Patterns

Base Component (Wrapper Pattern)

<!-- components/base/BaseButton.vue -->
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  loading: false,
  disabled: false
})

const classes = computed(() => {
  const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2'

  const variants = {
    primary: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500',
    secondary: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
    danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
    ghost: 'hover:bg-gray-100 text-gray-700 focus:ring-gray-500'
  }

  const sizes = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-sm',
    lg: 'px-6 py-3 text-base'
  }

  return [
    base,
    variants[props.variant],
    sizes[props.size],
    (props.disabled || props.loading) && 'opacity-50 cursor-not-allowed'
  ]
})
</script>

<template>
  <button
    :class="classes"
    :disabled="disabled || loading"
    v-bind="$attrs"
  >
    <svg
      v-if="loading"
      class="animate-spin -ml-1 mr-2 h-4 w-4"
      fill="none"
      viewBox="0 0 24 24"
    >
      <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
      <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
    </svg>
    <slot />
  </button>
</template>

Renderless Component (Slot Props)

<!-- components/DataFetcher.vue -->
<script setup lang="ts" generic="T">
import { ref, onMounted, watch } from 'vue'

interface Props {
  url: string
  immediate?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  immediate: true
})

const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isLoading = ref(false)

async function fetch() {
  isLoading.value = true
  error.value = null
  try {
    const response = await window.fetch(props.url)
    data.value = await response.json()
  } catch (e) {
    error.value = e as Error
  } finally {
    isLoading.value = false
  }
}

onMounted(() => {
  if (props.immediate) fetch()
})

watch(() => props.url, fetch)
</script>

<template>
  <slot
    :data="data"
    :error="error"
    :is-loading="isLoading"
    :refresh="fetch"
  />
</template>

<!-- Usage -->
<DataFetcher url="/api/employees" v-slot="{ data, isLoading, error, refresh }">
  <div v-if="isLoading">Loading...</div>
  <div v-else-if="error">Error: {{ error.message }}</div>
  <EmployeeList v-else :employees="data" />
  <button @click="refresh">Refresh</button>
</DataFetcher>

Form Component with v-model

<!-- components/form/FormInput.vue -->
<script setup lang="ts">
interface Props {
  modelValue: string
  label?: string
  type?: string
  placeholder?: string
  error?: string
  required?: boolean
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  type: 'text',
  placeholder: '',
  required: false,
  disabled: false
})

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const inputId = `input-${Math.random().toString(36).slice(2, 9)}`

function handleInput(event: Event) {
  const target = event.target as HTMLInputElement
  emit('update:modelValue', target.value)
}
</script>

<template>
  <div class="form-group">
    <label
      v-if="label"
      :for="inputId"
      class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
    >
      {{ label }}
      <span v-if="required" class="text-red-500">*</span>
    </label>

    <input
      :id="inputId"
      :type="type"
      :value="modelValue"
      :placeholder="placeholder"
      :disabled="disabled"
      :required="required"
      :class="[
        'block w-full px-4 py-2.5 rounded-lg border shadow-sm transition-colors',
        error
          ? 'border-red-500 focus:ring-red-500 focus:border-red-500'
          : 'border-gray-300 focus:ring-blue-500 focus:border-blue-500',
        disabled && 'bg-gray-100 cursor-not-allowed'
      ]"
      @input="handleInput"
    />

    <p v-if="error" class="mt-1 text-sm text-red-500">
      {{ error }}
    </p>
  </div>
</template>

TypeScript Integration

Type Definitions

// types/index.ts
export interface Employee {
  id: string
  name: string
  email: string
  department: string
  position: string
  status: 'active' | 'inactive' | 'on_leave'
  hireDate: string
  createdAt: string
  updatedAt: string
}

export interface Attendance {
  id: string
  employeeId: string
  date: string
  checkIn: string | null
  checkOut: string | null
  status: 'present' | 'absent' | 'late' | 'early_leave'
  notes?: string
}

export interface PaginatedResponse<T> {
  data: T[]
  meta: {
    currentPage: number
    lastPage: number
    perPage: number
    total: number
  }
}

// API Response types
export type ApiResponse<T> = {
  success: true
  data: T
} | {
  success: false
  error: string
}

Typed API Client

// api/client.ts
import type { Employee, PaginatedResponse, ApiResponse } from '@/types'

const BASE_URL = '/api'

async function request<T>(
  endpoint: string,
  options: RequestInit = {}
): Promise<T> {
  const response = await fetch(`${BASE_URL}${endpoint}`, {
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      ...options.headers
    },
    ...options
  })

  if (!response.ok) {
    throw new Error(`API Error: ${response.status}`)
  }

  return response.json()
}

export const employeeApi = {
  getAll: () => request<PaginatedResponse<Employee>>('/employees'),

  getById: (id: string) => request<Employee>(`/employees/${id}`),

  create: (data: Partial<Employee>) =>
    request<Employee>('/employees', {
      method: 'POST',
      body: JSON.stringify(data)
    }),

  update: (id: string, data: Partial<Employee>) =>
    request<Employee>(`/employees/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data)
    }),

  delete: (id: string) =>
    request<void>(`/employees/${id}`, { method: 'DELETE' })
}

Best Practices

Component Naming

# PascalCase for components
BaseButton.vue
EmployeeCard.vue
DashboardStats.vue

# Kebab-case for template usage
<base-button />
<employee-card />
<dashboard-stats />

Props Best Practices

// ✅ Good - Type-safe with defaults
interface Props {
  title: string
  count?: number
  variant?: 'primary' | 'secondary'
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  variant: 'primary'
})

// ❌ Avoid - Runtime validation only
const props = defineProps({
  title: String,
  count: Number
})

Event Naming

// ✅ Good - Descriptive event names
const emit = defineEmits<{
  'update:modelValue': [value: string]
  'employee-selected': [employee: Employee]
  'form-submitted': [data: FormData]
}>()

// Usage in template
emit('employee-selected', employee)

// ❌ Avoid - Generic names
emit('change', value)
emit('click', data)

Ref vs Reactive

// Use ref for primitives
const count = ref(0)
const name = ref('')
const isLoading = ref(false)

// Use ref for objects you'll reassign
const employee = ref<Employee | null>(null)
employee.value = await fetchEmployee(id)

// Use reactive for objects you'll mutate
const form = reactive({
  name: '',
  email: '',
  department: ''
})
form.name = 'John'

Template Organization

<template>
  <!-- 1. Loading State -->
  <LoadingSpinner v-if="isLoading" />

  <!-- 2. Error State -->
  <ErrorMessage v-else-if="error" :message="error" />

  <!-- 3. Empty State -->
  <EmptyState v-else-if="!data?.length" />

  <!-- 4. Main Content -->
  <div v-else>
    <!-- Content here -->
  </div>
</template>