Agent Skills: Vue Component Patterns

Use when Vue component patterns including props, emits, slots, and provide/inject. Use when building reusable Vue components.

UncategorizedID: thebushidocollective/han/vue-component-patterns

Install this agent skill to your local

pnpm dlx add-skill https://github.com/TheBushidoCollective/han/tree/HEAD/plugins/frameworks/vue/skills/vue-component-patterns

Skill Files

Browse the full folder contents for vue-component-patterns.

Download Skill

Loading file tree…

plugins/frameworks/vue/skills/vue-component-patterns/SKILL.md

Skill Metadata

Name
vue-component-patterns
Description
Use when Vue component patterns including props, emits, slots, and provide/inject. Use when building reusable Vue components.

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>

    <p>Card content goes here</p>

    <template #footer>
      <button>Action</button>
    </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>

    <!-- Cache inactive components -->
    <KeepAlive>
      <component :is="tabs[currentTab]" />
    </KeepAlive>

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

    <!-- Max cached instances -->
    <KeepAlive :max="3">
      <component :is="tabs[currentTab]" />
    </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

  1. Single Responsibility - Each component should do one thing well
  2. Props down, events up - Data flows down via props, changes flow up via events
  3. Use TypeScript - Type-safe props and emits prevent bugs
  4. Validate props - Use runtime validation for critical props
  5. Provide defaults - Use withDefaults for optional props
  6. Use scoped slots - Share component state with consumers
  7. Avoid prop drilling - Use provide/inject for deep passing
  8. Use v-model for two-way binding - Especially for form inputs
  9. Compose with slots - Make components flexible and reusable
  10. Keep components small - Extract complex logic to composables

Component Anti-Patterns

  1. Mutating props - Props are readonly, emit events instead
  2. Tight coupling - Components shouldn't know about their parents
  3. Global state in components - Use composables or stores instead
  4. Too many props - Consider slots or composition
  5. Nested v-model - Can cause confusion, be explicit
  6. Not using TypeScript - Loses type safety and DX
  7. Overusing provide/inject - Use for app-level state, not everything
  8. No prop validation - Can lead to runtime errors
  9. Mixing concerns - Separate UI, logic, and data fetching
  10. 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