Claude Code Plugins

Community-maintained marketplace

Feedback
0
1

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

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name vuejs-patterns
description Vue.js 3 Composition API patterns and best practices. Use when creating Vue components, composables, or any reactive frontend code with Vue.js.
allowed-tools Read, Grep, Glob, Edit, Write

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>