web-framework-vue-composition-api · git:20260202.b236384 · 2026-02-02 · sha256 cdaa93e3fa39cb24
web-framework-vue-composition-api git:20260202.b236384B
Immutable. This exact content is served forever at /api/v1/blob/cdaa93e3fa39cb24.
---
name: web-framework-vue-composition-api
description: Vue 3 Composition API patterns, reactivity primitives, composables, lifecycle hooks
---
# Vue 3 Composition API
> **Quick Guide:** Use `<script setup>` for cleaner components. `ref()` for primitives, `reactive()` for objects. Extract reusable logic into composables (`use*` functions). Clean up side effects in `onUnmounted`. Use `defineProps`, `defineEmits`, `defineExpose` for component interfaces.
---
<critical_requirements>
## CRITICAL: Before Using This Skill
> **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
**(You MUST use `<script setup>` syntax for all new Vue components)**
**(You MUST clean up all side effects (timers, listeners, subscriptions) in `onUnmounted`)**
**(You MUST use `ref()` for primitives and `reactive()` for objects - access ref values via `.value`)**
**(You MUST prefix all composable functions with `use` following Vue conventions)**
**(You MUST use `defineExpose()` to expose methods/properties to parent components)**
</critical_requirements>
---
**Auto-detection:** Vue 3 Composition API, script setup, ref, reactive, computed, watch, watchEffect, composables, onMounted, onUnmounted, defineProps, defineEmits, defineExpose, defineModel, useTemplateRef, useId, onWatcherCleanup, provide, inject, Suspense
**When to use:**
- Building Vue 3 components using Composition API
- Creating reusable composables (use\* functions)
- Managing reactive state with ref/reactive
- Handling component lifecycle and side effects
- TypeScript integration with Vue components
**Key patterns covered:**
- Script setup syntax and compiler macros
- Reactivity primitives (ref, reactive, computed, watch, watchEffect)
- Lifecycle hooks (onMounted, onUnmounted, onUpdated)
- Composables pattern for logic reuse
- Template refs with useTemplateRef() (Vue 3.5+)
- defineModel() for v-model binding (Vue 3.4+)
- useId() for SSR-safe unique IDs (Vue 3.5+)
- Provide/Inject for dependency injection
- Async components and Suspense
**When NOT to use:**
- Simple components where Options API is cleaner
- Components that don't benefit from logic extraction
- When team has no Composition API experience (consider gradual adoption)
**Detailed Resources:**
- For code examples, see [examples/](examples/) folder
- For decision frameworks and anti-patterns, see [reference.md](reference.md)
---
<philosophy>
## Philosophy
The Composition API enables organizing code by **logical concern** rather than by option type (data, methods, computed). This makes complex components more maintainable and enables powerful logic reuse through composables. Code is naturally TypeScript-friendly with minimal type annotations needed.
**Core principles:**
1. **Composition over configuration** - Group related logic together instead of splitting across options
2. **Explicit reactivity** - State is explicitly reactive via `ref()` and `reactive()`
3. **Logic reuse via composables** - Extract and share stateful logic between components
4. **TypeScript-first** - Types flow naturally without excessive annotations
</philosophy>
---
<patterns>
## Core Patterns
### Pattern 1: Script Setup Syntax
Use `<script setup>` for cleaner, more concise components. Variables and imports are automatically exposed to the template.
#### Basic Structure
```vue
<script setup lang="ts">
import { ref, computed } from "vue";
import type { User } from "@/types";
// Props and emits
const props = defineProps<{
userId: string;
initialCount?: number;
}>();
const emit = defineEmits<{
update: [value: number];
submit: [];
}>();
// Reactive state
const count = ref(props.initialCount ?? 0);
const user = ref<User | null>(null);
// Computed values
const doubleCount = computed(() => count.value * 2);
// Methods
function increment() {
count.value++;
emit("update", count.value);
}
</script>
<template>
<div>
<p>Count: {{ count }} (Double: {{ doubleCount }})</p>
<button @click="increment">Increment</button>
</div>
</template>
```
**Why good:** All variables/functions automatically available in template, no explicit return needed, cleaner syntax with less boilerplate, TypeScript types flow naturally, named exports from imports work directly
---
### Pattern 2: Reactivity Primitives
Use `ref()` for primitives and `reactive()` for objects. Access ref values via `.value` in script, automatic unwrapping in templates.
#### ref() for Primitives
```typescript
import { ref } from "vue";
const MAX_COUNT = 100;
// ✅ Good Example - ref for primitives
const count = ref(0);
const name = ref("");
const isLoading = ref(false);
// Access/modify via .value
count.value++;
console.log(count.value); // 1
// Compare against named constants
if (count.value >= MAX_COUNT) {
count.value = MAX_COUNT;
}
```
**Why good:** Reactivity is explicit and trackable, primitives can be passed by reference, `.value` makes reactive access obvious, template unwraps automatically
#### reactive() for Objects
```typescript
import { reactive } from "vue";
// ✅ Good Example - reactive for objects
const state = reactive({
user: null as User | null,
settings: {
theme: "light",
notifications: true,
},
});
// Direct property access (no .value)
state.user = fetchedUser;
state.settings.theme = "dark";
```
**Why good:** Deep reactivity by default, no `.value` needed for property access, intuitive object manipulation, nested properties are reactive
#### computed() for Derived State
```typescript
import { ref, computed } from "vue";
const firstName = ref("John");
const lastName = ref("Doe");
// ✅ Read-only computed
const fullName = computed(() => `${firstName.value} ${lastName.value}`);
// ✅ Writable computed
const fullNameWritable = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value: string) => {
const [first, last] = value.split(" ");
firstName.value = first;
lastName.value = last ?? "";
},
});
```
**Why good:** Cached until dependencies change, clearly expresses derived state, supports both read-only and writable patterns
---
### Pattern 3: Watch and WatchEffect
Use `watch()` when you need access to previous values or explicit sources. Use `watchEffect()` for automatic dependency tracking.
#### watch() Pattern
```typescript
import { ref, watch } from "vue";
const searchQuery = ref("");
const results = ref<SearchResult[]>([]);
const DEBOUNCE_DELAY_MS = 300;
// ✅ Watch specific source with old/new values
watch(
searchQuery,
async (newQuery, oldQuery) => {
if (newQuery !== oldQuery && newQuery.length > 0) {
results.value = await searchApi(newQuery);
}
},
{
immediate: false, // Don't run on mount (default)
},
);
// ✅ Watch multiple sources
watch([firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(
`Name changed from ${oldFirst} ${oldLast} to ${newFirst} ${newLast}`,
);
});
// ✅ Watch reactive object property (use getter)
const state = reactive({ count: 0 });
watch(
() => state.count, // Getter required for reactive properties
(newCount) => console.log(`Count is now ${newCount}`),
);
```
**Why good:** Explicit about what's being watched, access to old values for comparison, lazy by default (runs on change, not mount)
#### watchEffect() Pattern
```typescript
import { ref, watchEffect } from "vue";
const userId = ref<string | null>(null);
const userData = ref<User | null>(null);
// ✅ Automatic dependency tracking
watchEffect(async () => {
if (userId.value) {
userData.value = await fetchUser(userId.value);
}
});
// Runs immediately, re-runs when userId changes
```
**Why good:** Automatically tracks reactive dependencies, runs immediately, simpler when you don't need old values
#### Cleanup in Watchers
```typescript
import { watch, onWatcherCleanup } from "vue";
const searchQuery = ref("");
watch(searchQuery, async (query) => {
const controller = new AbortController();
// ✅ Vue 3.5+ cleanup pattern
onWatcherCleanup(() => controller.abort());
try {
const results = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
// handle results
} catch (e) {
if (e.name !== "AbortError") throw e;
}
});
```
**Why good:** Prevents race conditions, cancels in-flight requests when source changes, clean async handling
---
### Pattern 4: Lifecycle Hooks
Register lifecycle callbacks with `onMounted`, `onUnmounted`, etc. Always clean up side effects.
#### Basic Lifecycle Pattern
```typescript
import { ref, onMounted, onUnmounted } from "vue";
const POLL_INTERVAL_MS = 5000;
export function useDataPolling(fetchFn: () => Promise<void>) {
const isPolling = ref(false);
let intervalId: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
isPolling.value = true;
// Initial fetch
fetchFn();
// Start polling
intervalId = setInterval(fetchFn, POLL_INTERVAL_MS);
});
onUnmounted(() => {
// ✅ Always clean up
isPolling.value = false;
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
});
return { isPolling };
}
```
**Why good:** Clear setup/teardown pairing, prevents memory leaks, cleanup always runs on unmount
#### Event Listener Pattern
```typescript
import { onMounted, onUnmounted } from "vue";
// ✅ Good Example - Reusable event listener composable
export function useEventListener<K extends keyof WindowEventMap>(
target: Window,
event: K,
callback: (e: WindowEventMap[K]) => void,
) {
onMounted(() => target.addEventListener(event, callback));
onUnmounted(() => target.removeEventListener(event, callback));
}
// Usage
const { width, height } = useWindowSize();
export function useWindowSize() {
const width = ref(window.innerWidth);
const height = ref(window.innerHeight);
useEventListener(window, "resize", () => {
width.value = window.innerWidth;
height.value = window.innerHeight;
});
return { width, height };
}
```
**Why good:** Encapsulates setup/cleanup logic, reusable across components, prevents listener leaks
---
### Pattern 5: Composables
Extract reusable stateful logic into composable functions prefixed with `use`.
#### Composable Structure
```typescript
// composables/use-counter.ts
import { ref, computed } from "vue";
const DEFAULT_INITIAL_VALUE = 0;
const DEFAULT_STEP = 1;
interface UseCounterOptions {
initialValue?: number;
step?: number;
min?: number;
max?: number;
}
export function useCounter(options: UseCounterOptions = {}) {
const {
initialValue = DEFAULT_INITIAL_VALUE,
step = DEFAULT_STEP,
min = -Infinity,
max = Infinity,
} = options;
const count = ref(initialValue);
const isAtMin = computed(() => count.value <= min);
const isAtMax = computed(() => count.value >= max);
function increment() {
if (count.value + step <= max) {
count.value += step;
}
}
function decrement() {
if (count.value - step >= min) {
count.value -= step;
}
}
function reset() {
count.value = initialValue;
}
// ✅ Return object with refs (enables destructuring while keeping reactivity)
return {
count,
isAtMin,
isAtMax,
increment,
decrement,
reset,
};
}
```
**Why good:** Encapsulates related state and logic, returns refs for reactivity preservation, configurable via options object, named constants for defaults
#### Composable Usage
```vue
<script setup lang="ts">
import { useCounter } from "@/composables/use-counter";
const MAX_QUANTITY = 99;
const MIN_QUANTITY = 1;
const {
count: quantity,
increment,
decrement,
isAtMax,
} = useCounter({
initialValue: 1,
min: MIN_QUANTITY,
max: MAX_QUANTITY,
step: 1,
});
</script>
<template>
<div>
<button @click="decrement">-</button>
<span>{{ quantity }}</span>
<button @click="increment" :disabled="isAtMax">+</button>
</div>
</template>
```
**Why good:** Destructuring renames avoid conflicts, reactivity preserved through refs, logic completely encapsulated
#### Async Composable Pattern
```typescript
// composables/use-fetch.ts
import {
ref,
toValue,
watchEffect,
type MaybeRefOrGetter,
type Ref,
} from "vue";
interface UseFetchReturn<T> {
data: Ref<T | null>;
error: Ref<Error | null>;
isLoading: Ref<boolean>;
refetch: () => Promise<void>;
}
export function useFetch<T>(url: MaybeRefOrGetter<string>): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>;
const error = ref<Error | null>(null);
const isLoading = ref(false);
async function fetchData() {
isLoading.value = true;
error.value = null;
try {
const response = await fetch(toValue(url));
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
data.value = await response.json();
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e));
} finally {
isLoading.value = false;
}
}
// Re-fetch when URL changes
watchEffect(() => {
fetchData();
});
return {
data,
error,
isLoading,
refetch: fetchData,
};
}
```
**Why good:** Accepts refs/getters for reactive URLs, automatic refetch on dependency change, complete loading/error state, explicit refetch capability
---
### Pattern 6: defineModel for v-model (Vue 3.4+)
Use `defineModel()` macro to simplify two-way binding on custom components. Replaces manual `defineProps` + `defineEmits` pattern.
#### Basic defineModel
```vue
<!-- ✅ Good Example - Child component with defineModel -->
<script setup lang="ts">
// defineModel creates both the prop and emit automatically
const model = defineModel<string>();
</script>
<template>
<input v-model="model" type="text" />
</template>
```
```vue
<!-- Parent component -->
<script setup lang="ts">
import { ref } from "vue";
import TextInput from "./TextInput.vue";
const text = ref("");
</script>
<template>
<TextInput v-model="text" />
</template>
```
**Why good:** Single line replaces defineProps + defineEmits + manual emit, ref-like API for direct mutation, works directly with native v-model
#### Named Models (Multiple v-models)
```vue
<!-- ✅ Good Example - Multiple v-model bindings -->
<script setup lang="ts">
const firstName = defineModel<string>("firstName");
const lastName = defineModel<string>("lastName");
</script>
<template>
<input v-model="firstName" placeholder="First name" />
<input v-model="lastName" placeholder="Last name" />
</template>
```
```vue
<!-- Parent usage -->
<template>
<UserName v-model:first-name="first" v-model:last-name="last" />
</template>
```
**Why good:** Cleaner than manual prop/emit for multiple bindings, kebab-case in template maps to camelCase in script
#### defineModel with Options
```vue
<script setup lang="ts">
// Required model
const title = defineModel<string>("title", { required: true });
// Model with default
const count = defineModel<number>({ default: 0 });
</script>
```
#### defineModel with Modifiers
```vue
<!-- ✅ Good Example - Handling v-model modifiers -->
<script setup lang="ts">
const [model, modifiers] = defineModel<string>({
set(value) {
// Transform value on set based on modifiers
if (modifiers.capitalize && value) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
return value;
},
});
</script>
<template>
<input v-model="model" type="text" />
</template>
```
```vue
<!-- Parent with modifier -->
<template>
<TextInput v-model.capitalize="text" />
</template>
```
**Why good:** Access to modifiers via destructure, setter transforms value before emit, clean handling of custom modifiers
---
### Pattern 7: Template Refs with useTemplateRef (Vue 3.5+)
Use `useTemplateRef()` for template references, especially for dynamic refs. Falls back to `ref()` pattern for simple static refs.
#### useTemplateRef (Vue 3.5+)
```vue
<script setup lang="ts">
import { useTemplateRef, onMounted } from "vue";
// ✅ Good Example - useTemplateRef for template refs
const inputRef = useTemplateRef<HTMLInputElement>("input");
onMounted(() => {
inputRef.value?.focus();
});
</script>
<template>
<input ref="input" type="text" />
</template>
```
**Why good:** String-based ref binding, works with dynamic ref IDs, clearer separation between reactive refs and template refs
#### Traditional ref() Pattern (All Vue 3 versions)
```vue
<script setup lang="ts">
import { ref, onMounted } from "vue";
// ✅ Still valid - ref name matches template ref attribute
const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => {
// Focus input on mount
inputRef.value?.focus();
});
function selectAll() {
inputRef.value?.select();
}
</script>
<template>
<input ref="inputRef" type="text" />
<button @click="selectAll">Select All</button>
</template>
```
**Why good:** Type-safe DOM access, null safety with optional chaining, ref name matches template attribute
#### Component Refs with defineExpose
```vue
<!-- ChildComponent.vue -->
<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
function increment() {
count.value++;
}
function reset() {
count.value = 0;
}
// ✅ Explicitly expose what parent can access
defineExpose({
count,
increment,
reset,
});
</script>
```
```vue
<!-- ParentComponent.vue -->
<script setup lang="ts">
import { ref } from "vue";
import ChildComponent from "./ChildComponent.vue";
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null);
function resetChild() {
childRef.value?.reset();
}
</script>
<template>
<ChildComponent ref="childRef" />
<button @click="resetChild">Reset Child</button>
</template>
```
**Why good:** Script setup components are private by default, explicit public API via defineExpose, type-safe parent access with InstanceType
---
### Pattern 8: useId for Accessible IDs (Vue 3.5+)
Use `useId()` to generate unique IDs for form elements and ARIA attributes. IDs are stable across SSR and client renders.
```vue
<script setup lang="ts">
import { useId } from "vue";
// ✅ Good Example - SSR-safe unique ID for form accessibility
const id = useId();
</script>
<template>
<div>
<label :for="id">Email address</label>
<input :id="id" type="email" />
</div>
</template>
```
**Why good:** SSR-safe (no hydration mismatch), unique per component instance, multiple calls generate different IDs, replaces manual ID generation
#### Multiple IDs in One Component
```vue
<script setup lang="ts">
import { useId } from "vue";
// Each call generates a different ID
const nameId = useId();
const emailId = useId();
const passwordId = useId();
</script>
<template>
<form>
<div>
<label :for="nameId">Name</label>
<input :id="nameId" type="text" />
</div>
<div>
<label :for="emailId">Email</label>
<input :id="emailId" type="email" />
</div>
<div>
<label :for="passwordId">Password</label>
<input :id="passwordId" type="password" />
</div>
</form>
</template>
```
**Why good:** Each call produces unique ID, stable across server/client, no need for uuid libraries
---
### Pattern 10: Provide/Inject Dependency Injection
Share data between ancestor and descendant components without prop drilling.
#### Type-Safe Provide/Inject
```typescript
// injection-keys.ts
import type { InjectionKey, Ref } from "vue";
export interface ThemeContext {
theme: Ref<"light" | "dark">;
toggleTheme: () => void;
}
// ✅ Symbol key with type information
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol("theme");
```
```vue
<!-- ThemeProvider.vue -->
<script setup lang="ts">
import { ref, provide } from "vue";
import { THEME_KEY, type ThemeContext } from "@/injection-keys";
const theme = ref<"light" | "dark">("light");
function toggleTheme() {
theme.value = theme.value === "light" ? "dark" : "light";
}
// ✅ Provide with typed key
provide(THEME_KEY, {
theme,
toggleTheme,
});
</script>
<template>
<div :data-theme="theme">
<slot />
</div>
</template>
```
```vue
<!-- ConsumerComponent.vue -->
<script setup lang="ts">
import { inject } from "vue";
import { THEME_KEY } from "@/injection-keys";
// ✅ Type-safe injection with fallback
const themeContext = inject(THEME_KEY);
if (!themeContext) {
throw new Error("ConsumerComponent must be used within ThemeProvider");
}
const { theme, toggleTheme } = themeContext;
</script>
<template>
<button @click="toggleTheme">Current: {{ theme }}</button>
</template>
```
**Why good:** InjectionKey provides type safety, Symbol prevents key collisions, explicit error handling for missing providers
---
### Pattern 11: Props and Emits with TypeScript
Use defineProps and defineEmits with TypeScript for type-safe component interfaces.
#### Props with Reactive Destructure (Vue 3.5+) - Recommended
```vue
<script setup lang="ts">
// ✅ Reactive destructure with defaults (Vue 3.5+)
// Props are automatically reactive - no .value needed
const {
title,
count = 0,
items = () => [], // Factory for non-primitive defaults
} = defineProps<{
title: string;
count?: number;
items?: string[];
}>();
// ⚠️ IMPORTANT: Wrap destructured props in getter for watch/composables
watch(
() => count,
(newCount) => {
console.log("Count changed:", newCount);
},
);
</script>
```
**Why good:** Native JavaScript default syntax, cleaner than withDefaults, automatically reactive in Vue 3.5+
#### Props with withDefaults (Vue 3.4 and below)
```vue
<script setup lang="ts">
interface Props {
title: string;
count?: number;
items?: string[];
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => [], // Factory function for non-primitives
});
</script>
```
**Why good:** Still works in Vue 3.5+, explicit about defaults, familiar pattern
#### Emits with Validation
```vue
<script setup lang="ts">
// ✅ Named tuple syntax (Vue 3.3+)
const emit = defineEmits<{
update: [id: string, value: number];
delete: [id: string];
submit: [];
}>();
function handleUpdate(id: string, value: number) {
emit("update", id, value);
}
function handleDelete(id: string) {
emit("delete", id);
}
</script>
```
**Why good:** Type-safe props and emits, autocomplete in consumers, compile-time validation, self-documenting component API
---
### Pattern 12: Async Components and Suspense
Lazy-load components and handle async operations gracefully.
#### defineAsyncComponent
```typescript
import { defineAsyncComponent } from "vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import ErrorDisplay from "@/components/ErrorDisplay.vue";
const LOADING_DELAY_MS = 200;
const LOAD_TIMEOUT_MS = 10000;
// ✅ Async component with loading/error handling
const HeavyChart = defineAsyncComponent({
loader: () => import("@/components/HeavyChart.vue"),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: LOADING_DELAY_MS, // Delay before showing loading
timeout: LOAD_TIMEOUT_MS, // Timeout before showing error
});
```
**Why good:** Code splitting for better initial load, graceful loading states, error handling built-in, prevents loading flicker with delay
#### Suspense with Async Setup
```vue
<!-- AsyncUserProfile.vue -->
<script setup lang="ts">
// Top-level await makes this an async component
const user = await fetchUser(props.userId);
const posts = await fetchUserPosts(props.userId);
</script>
<template>
<div>
<h1>{{ user.name }}</h1>
<PostList :posts="posts" />
</div>
</template>
```
```vue
<!-- ParentComponent.vue -->
<script setup lang="ts">
import { ref } from "vue";
import AsyncUserProfile from "./AsyncUserProfile.vue";
const userId = ref("123");
</script>
<template>
<Suspense>
<template #default>
<AsyncUserProfile :user-id="userId" />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
```
**Why good:** Declarative loading states, coordinates multiple async dependencies, cleaner than manual loading state management
</patterns>
---
<integration>
## Integration Guide
**Vue Composition API is state and styling agnostic.** Components should accept props for data and emit events for communication. Use `class` attribute for styling.
**Works with:**
- **Vue Router**: Composables like `useRoute()`, `useRouter()` follow same patterns
- **Any CSS solution** via class binding and scoped styles
- **Any HTTP client** via composables wrapping fetch logic
**Component Communication:**
- Props down, events up for parent-child
- Provide/Inject for deep nesting
- External state management decisions are separate from component architecture
</integration>
---
<critical_reminders>
## CRITICAL REMINDERS
> **All code must follow project conventions in CLAUDE.md**
**(You MUST use `<script setup>` syntax for all new Vue components)**
**(You MUST clean up all side effects (timers, listeners, subscriptions) in `onUnmounted`)**
**(You MUST use `ref()` for primitives and `reactive()` for objects - access ref values via `.value`)**
**(You MUST prefix all composable functions with `use` following Vue conventions)**
**(You MUST use `defineExpose()` to expose methods/properties to parent components)**
**Failure to follow these rules will cause memory leaks, broken reactivity, and unmaintainable component APIs.**
</critical_reminders>