vue-component-patterns

Vue Component Patterns

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "vue-component-patterns" with this command: npx skills add thebushidocollective/han/thebushidocollective-han-vue-component-patterns

Vue Component Patterns

Master Vue component patterns to build reusable, maintainable components with proper prop validation, events, and composition.

Props Patterns

Basic Props with TypeScript

<script setup lang="ts"> interface Props { title: string; count?: number; items: string[]; }

const props = withDefaults(defineProps<Props>(), { count: 0, items: () => [] }); </script>

<template> <div> <h2>{{ title }}</h2> <p>Count: {{ count }}</p> <ul> <li v-for="item in items" :key="item">{{ item }}</li> </ul> </div> </template>

Advanced Prop Types

<script setup lang="ts"> import type { PropType } from 'vue';

type Status = 'pending' | 'success' | 'error';

interface User { id: number; name: string; email: string; }

interface Props { // Literal types status: Status;

// Complex objects user: User;

// Functions onUpdate: (value: string) => void;

// Generic arrays tags: string[];

// Object arrays users: User[];

// Nullable description: string | null;

// Union types value: string | number; }

const props = defineProps<Props>(); </script>

Runtime Props Validation

<script setup lang="ts"> import type { PropType } from 'vue';

type ButtonSize = 'sm' | 'md' | 'lg';

const props = defineProps({ // Type checking title: { type: String, required: true },

// Default values count: { type: Number, default: 0 },

// Multiple types value: { type: [String, Number], required: true },

// Object with type user: { type: Object as PropType<{ name: string; age: number }>, required: true },

// Array with type tags: { type: Array as PropType<string[]>, default: () => [] },

// Custom validator size: { type: String as PropType<ButtonSize>, default: 'md', validator: (value: string) => ['sm', 'md', 'lg'].includes(value) },

// Complex validator email: { type: String, validator: (value: string) => { return /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(value); } },

// Function prop onClick: { type: Function as PropType<(id: number) => void>, required: false } }); </script>

Props with Defaults

<script setup lang="ts"> interface Props { title?: string; count?: number; items?: string[]; user?: { name: string; email: string; }; options?: { enabled: boolean; timeout: number; }; }

// Simple defaults const props = withDefaults(defineProps<Props>(), { title: 'Default Title', count: 0 });

// Function defaults for objects/arrays const propsWithComplex = withDefaults(defineProps<Props>(), { title: 'Default', count: 0, items: () => [], user: () => ({ name: 'Guest', email: 'guest@example.com' }), options: () => ({ enabled: true, timeout: 5000 }) }); </script>

Emits Patterns

TypeScript Emits

<script setup lang="ts"> // Define emit types const emit = defineEmits<{ // No payload close: [];

// Single payload update: [value: string];

// Multiple payloads change: [id: number, value: string];

// Object payload submit: [data: { name: string; email: string }]; }>();

function handleClose() { emit('close'); }

function handleUpdate(value: string) { emit('update', value); }

function handleChange(id: number, value: string) { emit('change', id, value); }

function handleSubmit() { emit('submit', { name: 'John', email: 'john@example.com' }); } </script>

Runtime Emits Validation

<script setup lang="ts"> const emit = defineEmits({ // Basic event click: null,

// Validation update: (value: number) => { return value >= 0; },

// Complex validation submit: (payload: { email: string; password: string }) => { if (!payload.email || !payload.password) { console.warn('Invalid submit payload'); return false; } return true; } }); </script>

Custom v-model

<!-- CustomInput.vue --> <script setup lang="ts"> interface Props { modelValue: string; }

const props = defineProps<Props>();

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

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

<template> <input :value="modelValue" @input="handleInput" type="text" /> </template>

<!-- Usage --> <script setup lang="ts"> import { ref } from 'vue'; import CustomInput from './CustomInput.vue';

const text = ref(''); </script>

<template> <CustomInput v-model="text" /> </template>

Multiple v-models

<!-- RangeSlider.vue --> <script setup lang="ts"> interface Props { min: number; max: number; }

const props = defineProps<Props>();

const emit = defineEmits<{ 'update:min': [value: number]; 'update:max': [value: number]; }>(); </script>

<template> <div> <input type="range" :value="min" @input="emit('update:min', Number($event.target.value))" /> <input type="range" :value="max" @input="emit('update:max', Number($event.target.value))" /> </div> </template>

<!-- Usage --> <script setup lang="ts"> import { ref } from 'vue';

const minValue = ref(0); const maxValue = ref(100); </script>

<template> <RangeSlider v-model:min="minValue" v-model:max="maxValue" /> </template>

Slots Patterns

Basic Slots

<!-- Card.vue --> <template> <div class="card"> <header v-if="$slots.header"> <slot name="header" /> </header> <main> <slot /> </main> <footer v-if="$slots.footer"> <slot name="footer" /> </footer> </div> </template>

<!-- Usage --> <template> <Card> <template #header> <h1>Card Title</h1> </template>

&#x3C;p>Card content goes here&#x3C;/p>

&#x3C;template #footer>
  &#x3C;button>Action&#x3C;/button>
&#x3C;/template>

</Card> </template>

Scoped Slots

<!-- List.vue --> <script setup lang="ts" generic="T"> interface Props { items: T[]; }

const props = defineProps<Props>(); </script>

<template> <div> <div v-for="(item, index) in items" :key="index"> <slot :item="item" :index="index" /> </div> </div> </template>

<!-- Usage --> <script setup lang="ts"> interface User { id: number; name: string; email: string; }

const users: User[] = [ { id: 1, name: 'John', email: 'john@example.com' }, { id: 2, name: 'Jane', email: 'jane@example.com' } ]; </script>

<template> <List :items="users"> <template #default="{ item, index }"> <div> {{ index + 1 }}. {{ item.name }} - {{ item.email }} </div> </template> </List> </template>

Fallback Slot Content

<!-- Button.vue --> <template> <button> <slot> Click Me </slot> </button> </template>

<!-- Custom content --> <Button>Custom Text</Button>

<!-- Uses fallback --> <Button />

Dynamic Slots

<!-- DynamicSlots.vue --> <script setup lang="ts"> import { useSlots } from 'vue';

const slots = useSlots();

// Check if slot exists const hasHeader = !!slots.header;

// Access slot props const headerProps = slots.header?.(); </script>

<template> <div> <div v-if="hasHeader" class="header"> <slot name="header" /> </div> <slot /> </div> </template>

Renderless Components with Slots

<!-- Mouse.vue - Renderless component --> <script setup lang="ts"> import { ref, onMounted, onUnmounted } from 'vue';

const x = ref(0); const y = ref(0);

function update(event: MouseEvent) { x.value = event.pageX; y.value = event.pageY; }

onMounted(() => { window.addEventListener('mousemove', update); });

onUnmounted(() => { window.removeEventListener('mousemove', update); }); </script>

<template> <slot :x="x" :y="y" /> </template>

<!-- Usage --> <template> <Mouse v-slot="{ x, y }"> <p>Mouse position: {{ x }}, {{ y }}</p> </Mouse> </template>

Provide and Inject for Deep Passing

Basic Provide/Inject

<!-- Parent.vue --> <script setup lang="ts"> import { provide, ref } from 'vue';

const theme = ref('dark');

function toggleTheme() { theme.value = theme.value === 'dark' ? 'light' : 'dark'; }

provide('theme', theme); provide('toggleTheme', toggleTheme); </script>

<!-- Child.vue (any depth) --> <script setup lang="ts"> import { inject, type Ref } from 'vue';

const theme = inject<Ref<string>>('theme'); const toggleTheme = inject<() => void>('toggleTheme'); </script>

<template> <div :class="theme"> <button @click="toggleTheme">Toggle Theme</button> </div> </template>

Type-Safe Provide/Inject

// types.ts import type { InjectionKey, Ref } from 'vue';

export interface AppConfig { apiUrl: string; timeout: number; }

export interface User { id: number; name: string; email: string; }

export const ConfigKey: InjectionKey<AppConfig> = Symbol('config'); export const UserKey: InjectionKey<Ref<User | null>> = Symbol('user');

// Provider <script setup lang="ts"> import { provide, ref } from 'vue'; import { ConfigKey, UserKey } from './types';

const config: AppConfig = { apiUrl: 'https://api.example.com', timeout: 5000 };

const user = ref<User | null>(null);

provide(ConfigKey, config); provide(UserKey, user); </script>

// Consumer <script setup lang="ts"> import { inject } from 'vue'; import { ConfigKey, UserKey } from './types';

const config = inject(ConfigKey); const user = inject(UserKey);

// Fully typed! console.log(config?.apiUrl); console.log(user?.value?.name); </script>

Provide/Inject with Reactivity

<!-- App.vue --> <script setup lang="ts"> import { provide, reactive, readonly } from 'vue';

interface State { count: number; user: { name: string }; }

const state = reactive<State>({ count: 0, user: { name: 'John' } });

function increment() { state.count++; }

// Provide readonly to prevent mutations provide('state', readonly(state)); provide('increment', increment); </script>

<!-- Consumer --> <script setup lang="ts"> import { inject } from 'vue';

const state = inject('state'); const increment = inject('increment'); </script>

<template> <div> <p>Count: {{ state.count }}</p> <button @click="increment">Increment</button> </div> </template>

Component Registration

Global Registration

// main.ts import { createApp } from 'vue'; import App from './App.vue'; import BaseButton from './components/BaseButton.vue'; import BaseInput from './components/BaseInput.vue';

const app = createApp(App);

// Register globally app.component('BaseButton', BaseButton); app.component('BaseInput', BaseInput);

app.mount('#app');

// Use anywhere without importing <template> <BaseButton>Click</BaseButton> <BaseInput v-model="text" /> </template>

Local Registration

<script setup lang="ts"> import BaseButton from './components/BaseButton.vue'; import BaseInput from './components/BaseInput.vue';

// Automatically registered in this component </script>

<template> <BaseButton>Click</BaseButton> <BaseInput v-model="text" /> </template>

Auto-Import Components

// vite.config.ts import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import Components from 'unplugin-vue-components/vite';

export default defineConfig({ plugins: [ vue(), Components({ // Auto import from components directory dirs: ['src/components'], // Generate types dts: true }) ] });

// Now use components without importing <template> <BaseButton>No import needed!</BaseButton> </template>

Async Components

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

// Basic async component const AsyncComponent = defineAsyncComponent(() => import('./components/Heavy.vue') );

// With loading and error states const AsyncWithOptions = defineAsyncComponent({ loader: () => import('./components/Heavy.vue'), loadingComponent: LoadingSpinner, errorComponent: ErrorDisplay, delay: 200, timeout: 3000 }); </script>

<template> <Suspense> <AsyncComponent /> <template #fallback> <div>Loading...</div> </template> </Suspense> </template>

Teleport for Modals and Portals

<!-- Modal.vue --> <script setup lang="ts"> import { ref } from 'vue';

interface Props { show: boolean; }

const props = defineProps<Props>(); const emit = defineEmits<{ close: []; }>(); </script>

<template> <Teleport to="body"> <div v-if="show" class="modal-backdrop" @click="emit('close')"> <div class="modal" @click.stop> <slot /> </div> </div> </Teleport> </template>

<style scoped> .modal-backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; }

.modal { background: white; padding: 2rem; border-radius: 8px; } </style>

<!-- Usage --> <script setup lang="ts"> import { ref } from 'vue'; import Modal from './Modal.vue';

const showModal = ref(false); </script>

<template> <button @click="showModal = true">Open Modal</button>

<Modal :show="showModal" @close="showModal = false"> <h2>Modal Content</h2> <p>This is teleported to body!</p> </Modal> </template>

KeepAlive for Component Caching

<script setup lang="ts"> import { ref } from 'vue'; import TabA from './TabA.vue'; import TabB from './TabB.vue'; import TabC from './TabC.vue';

const currentTab = ref('TabA');

const tabs = { TabA, TabB, TabC }; </script>

<template> <div> <button v-for="(_, tab) in tabs" :key="tab" @click="currentTab = tab" > {{ tab }} </button>

&#x3C;!-- Cache inactive components -->
&#x3C;KeepAlive>
  &#x3C;component :is="tabs[currentTab]" />
&#x3C;/KeepAlive>

&#x3C;!-- Include/exclude specific components -->
&#x3C;KeepAlive :include="['TabA', 'TabB']">
  &#x3C;component :is="tabs[currentTab]" />
&#x3C;/KeepAlive>

&#x3C;!-- Max cached instances -->
&#x3C;KeepAlive :max="3">
  &#x3C;component :is="tabs[currentTab]" />
&#x3C;/KeepAlive>

</div> </template>

Higher-Order Components

// withLoading.ts import { defineComponent, h, ref, onMounted } from 'vue';

export function withLoading(Component: any, loadFn: () => Promise<void>) { return defineComponent({ setup(props, { attrs, slots }) { const loading = ref(true); const error = ref<Error | null>(null);

  onMounted(async () => {
    try {
      await loadFn();
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  });

  return () => {
    if (loading.value) {
      return h('div', 'Loading...');
    }
    if (error.value) {
      return h('div', `Error: ${error.value.message}`);
    }
    return h(Component, { ...props, ...attrs }, slots);
  };
}

}); }

// Usage const UserProfile = withLoading( UserProfileComponent, async () => { // Load user data } );

When to Use This Skill

Use vue-component-patterns when building modern, production-ready applications that require:

  • Reusable component libraries

  • Complex component communication

  • Type-safe component APIs

  • Flexible content projection with slots

  • Deep prop passing without prop drilling

  • Modal and portal management

  • Component performance optimization

  • Large-scale component architectures

Component Design Best Practices

  • Single Responsibility - Each component should do one thing well

  • Props down, events up - Data flows down via props, changes flow up via events

  • Use TypeScript - Type-safe props and emits prevent bugs

  • Validate props - Use runtime validation for critical props

  • Provide defaults - Use withDefaults for optional props

  • Use scoped slots - Share component state with consumers

  • Avoid prop drilling - Use provide/inject for deep passing

  • Use v-model for two-way binding - Especially for form inputs

  • Compose with slots - Make components flexible and reusable

  • Keep components small - Extract complex logic to composables

Component Anti-Patterns

  • Mutating props - Props are readonly, emit events instead

  • Tight coupling - Components shouldn't know about their parents

  • Global state in components - Use composables or stores instead

  • Too many props - Consider slots or composition

  • Nested v-model - Can cause confusion, be explicit

  • Not using TypeScript - Loses type safety and DX

  • Overusing provide/inject - Use for app-level state, not everything

  • No prop validation - Can lead to runtime errors

  • Mixing concerns - Separate UI, logic, and data fetching

  • Not cleaning up - Remove event listeners in onUnmounted

Common Component Patterns

Form Input Component

<script setup lang="ts"> interface Props { modelValue: string; label?: string; error?: string; placeholder?: string; required?: boolean; }

const props = defineProps<Props>();

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

<template> <div class="form-field"> <label v-if="label"> {{ label }} <span v-if="required" class="required">*</span> </label> <input :value="modelValue" :placeholder="placeholder" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @blur="emit('blur')" /> <span v-if="error" class="error">{{ error }}</span> </div> </template>

Data Table Component

<script setup lang="ts" generic="T"> interface Column<T> { key: keyof T; label: string; sortable?: boolean; }

interface Props { data: T[]; columns: Column<T>[]; }

const props = defineProps<Props>();

const emit = defineEmits<{ sort: [column: keyof T]; rowClick: [item: T]; }>(); </script>

<template> <table> <thead> <tr> <th v-for="col in columns" :key="String(col.key)" @click="col.sortable && emit('sort', col.key)" > {{ col.label }} </th> </tr> </thead> <tbody> <tr v-for="(item, index) in data" :key="index" @click="emit('rowClick', item)" > <td v-for="col in columns" :key="String(col.key)"> <slot :name="cell-${String(col.key)}" :item="item"> {{ item[col.key] }} </slot> </td> </tr> </tbody> </table> </template>

Resources

  • Vue 3 Component Documentation

  • Props Documentation

  • Events Documentation

  • Slots Documentation

  • Provide/Inject Documentation

  • TypeScript with Vue

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

android-jetpack-compose

No summary provided by upstream source.

Repository SourceNeeds Review
General

fastapi-async-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
General

storybook-story-writing

No summary provided by upstream source.

Repository SourceNeeds Review