vue-conventions

star 7

Vue 3 Composition API patterns with TypeScript, Pinia, composables, and performance

zb-ss By zb-ss schedule Updated 2/9/2026

name: vue-conventions description: Vue 3 Composition API patterns with TypeScript, Pinia, composables, and performance license: MIT compatibility: opencode metadata: framework: vue version: "3.0+"

Composition API with <script setup>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import type { User } from '@/types'

// Props with defaults
const props = withDefaults(defineProps<{
  userId: number
  showAvatar?: boolean
}>(), {
  showAvatar: true
})

// Emits with types
const emit = defineEmits<{
  (e: 'update', user: User): void
  (e: 'delete', id: number): void
}>()

// Reactive state
const is_loading = ref(false)
const error_message = ref<string | null>(null)

// Store
const user_store = useUserStore()

// Computed
const full_name = computed(() => 
  `${user_store.current_user?.first_name} ${user_store.current_user?.last_name}`
)

// Methods
async function fetchUser(): Promise<void> {
  is_loading.value = true
  try {
    await user_store.fetchUser(props.userId)
  } catch (err) {
    error_message.value = 'Failed to load user'
  } finally {
    is_loading.value = false
  }
}

// Lifecycle
onMounted(() => {
  fetchUser()
})
</script>

<template>
  <div class="user-profile">
    <div v-if="is_loading">Loading...</div>
    <div v-else-if="error_message" class="error">{{ error_message }}</div>
    <template v-else>
      <img v-if="showAvatar" :src="user_store.current_user?.avatar" :alt="full_name">
      <h2>{{ full_name }}</h2>
    </template>
  </div>
</template>

Composables (Reusable Logic)

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

interface UseApiReturn<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  is_loading: Ref<boolean>
  execute: () => Promise<void>
}

export function useApi<T>(url: string): UseApiReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<string | null>(null)
  const is_loading = ref(false)
  
  async function execute(): Promise<void> {
    is_loading.value = true
    error.value = null
    
    try {
      const response = await fetch(url)
      if (!response.ok) throw new Error('Request failed')
      data.value = await response.json()
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Unknown error'
    } finally {
      is_loading.value = false
    }
  }
  
  return { data, error, is_loading, execute }
}

Pinia Store

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'

export const useUserStore = defineStore('user', () => {
  // State
  const current_user = ref<User | null>(null)
  const users = ref<User[]>([])
  
  // Getters
  const is_authenticated = computed(() => current_user.value !== null)
  const active_users = computed(() => users.value.filter(u => u.is_active))
  
  // Actions
  async function fetchUser(id: number): Promise<void> {
    const response = await fetch(`/api/users/${id}`)
    current_user.value = await response.json()
  }
  
  function logout(): void {
    current_user.value = null
  }
  
  return {
    current_user,
    users,
    is_authenticated,
    active_users,
    fetchUser,
    logout
  }
})

Component Communication

<!-- Parent -->
<template>
  <ChildComponent
    :user="selected_user"
    @update="handleUpdate"
    @delete="handleDelete"
  >
    <template #header>
      <h2>Custom Header</h2>
    </template>
  </ChildComponent>
</template>

<!-- Child with slots -->
<template>
  <div>
    <slot name="header">
      <h2>Default Header</h2>
    </slot>
    <div>{{ user.name }}</div>
    <button @click="$emit('update', user)">Update</button>
  </div>
</template>

Performance Patterns

<script setup lang="ts">
import { defineAsyncComponent, shallowRef } from 'vue'

// Lazy load heavy components
const HeavyChart = defineAsyncComponent(() => 
  import('@/components/HeavyChart.vue')
)

// Use shallowRef for large objects that don't need deep reactivity
const large_dataset = shallowRef<DataPoint[]>([])
</script>

<template>
  <!-- v-if for rarely shown elements -->
  <Modal v-if="is_modal_open" />
  
  <!-- v-show for frequently toggled -->
  <Tooltip v-show="is_hovering" />
  
  <!-- Always use key with v-for -->
  <UserCard 
    v-for="user in users" 
    :key="user.id" 
    :user="user"
  />
  
  <!-- Suspense for async components -->
  <Suspense>
    <template #default>
      <HeavyChart :data="chart_data" />
    </template>
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>

TypeScript Types

// types/index.ts
export interface User {
  id: number
  first_name: string
  last_name: string
  email: string
  avatar?: string
  is_active: boolean
  created_at: string
}

export interface ApiResponse<T> {
  data: T
  meta?: {
    total: number
    page: number
    per_page: number
  }
}

// Props type
export interface UserCardProps {
  user: User
  show_actions?: boolean
}

Error Handling

// Global error handler in main.ts
app.config.errorHandler = (err, instance, info) => {
  console.error('Global error:', err)
  // Send to error tracking service
}

// Component-level
import { onErrorCaptured } from 'vue'

onErrorCaptured((err, instance, info) => {
  error_message.value = 'Something went wrong'
  return false // Prevent propagation
})

Security

  • Never trust client validation - validate on backend
  • Use v-html only with sanitized content (DOMPurify)
  • Include CSRF tokens in API requests
  • Sanitize user input before display
Install via CLI
npx skills add https://github.com/zb-ss/opencode-workflows --skill vue-conventions
Repository Details
star Stars 7
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator