git:20260317.8413a9e to git:20260906.ae0cc61

123 added, 177 removed. Audit B to B.

---
name: web-framework-vue-composition-api
- description: Vue 3 Composition API patterns, reactivity primitives, composables, lifecycle hooks
+ description: Vue 3 Composition API — reactivity primitives, composables, lifecycle, the 3.4+ and 3.5+ macros. Load when writing Vue 3 components or composables.
---
# Vue 3 Composition API
- > **Quick Guide:** Use `<script setup>` for all components. `ref()` for primitives, `reactive()` for objects. Extract reusable logic into composables (`use*` functions). Clean up side effects in `onUnmounted`. Use `defineModel()` for v-model (3.4+), `useTemplateRef()` for DOM refs (3.5+), `onWatcherCleanup()` to cancel stale async work (3.5+). Destructured props require getter wrappers in `watch()`.
+ > **Quick Guide:** `<script setup>` for every component. `ref()` for primitives and anything reassigned, `reactive()` for an object mutated in place. Reusable stateful logic goes into a `use*` composable that returns an object of refs. Everything opened in `onMounted` is closed in `onUnmounted`. The 3.4+ and 3.5+ macros — `defineModel()`, `useTemplateRef()`, `useId()`, `onWatcherCleanup()` — replace whole patterns that preceded them, and destructured props need a getter wrapper in `watch()`.
+ **Detailed Resources:**
+
+ - [examples/core.md](examples/core.md) — a complete component, template refs, focus management
+ - [examples/reactivity.md](examples/reactivity.md) — `ref`, `reactive`, `computed` and their anti-patterns
+ - [examples/composables.md](examples/composables.md) — useFetch, useLocalStorage, useDebounce, useIntersectionObserver
+ - [examples/lifecycle.md](examples/lifecycle.md) — WebSockets, timers, event listeners, cleanup
+ - [examples/provide-inject.md](examples/provide-inject.md) — theme provider, typed injection keys
+ - [examples/define-expose.md](examples/define-expose.md) — form field validation, parent-child coordination
+ - [examples/vue-3-5-features.md](examples/vue-3-5-features.md) — defineModel, useTemplateRef, useId, onWatcherCleanup, reactive destructure, deferred Teleport
+ - [examples/async.md](examples/async.md) — lazy loading, Suspense, async setup
+ - [reference.md](reference.md) — decision trees, anti-patterns with corrected code, checklists, TypeScript patterns
+
---
- <critical_requirements>
+ ## Which path applies
- ## CRITICAL: Before Using This Skill
+ - **Vue on its own** — component data fetching goes through `watch`/`watchEffect` or an async composable, as Patterns 3 and 5 describe.
+ - **Vue under a meta-framework** — the framework owns fetching, caching and SSR-safe hydration through its own composables; take Patterns 1, 2, 4 and 6–11 unchanged and let it handle the rest.
- > **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)**
+ <critical_requirements>
- **(You MUST clean up all side effects (timers, listeners, subscriptions) in `onUnmounted`)**
+ ## Before writing Vue code
- **(You MUST use `ref()` for primitives and `reactive()` for objects - access ref values via `.value`)**
+ **Write components as `<script setup>`.** Bindings reach the template with no return statement, and the compiler macros — `defineProps`, `defineEmits`, `defineModel` — only exist inside it.
- **(You MUST prefix all composable functions with `use` following Vue conventions)**
+ **Pair every `onMounted` setup with an `onUnmounted` teardown.** Timers, listeners, observers and sockets outlive the component otherwise, and nothing reports it.
- **(You MUST wrap destructured props in a getter for `watch()` - `watch(() => count, ...)` not `watch(count, ...)`)**
+ **Pick `ref()` for primitives and anything you will reassign, `reactive()` for an object you mutate in place.** A reassigned `reactive` variable leaves the old proxy behind, still wired to the template.
- </critical_requirements>
+ **Prefix a composable with `use`.** The convention is what tells a reader — and the linter — that the function may call lifecycle hooks and must run during setup.
- ---
+ **Wrap a destructured prop in a getter for `watch()` — `watch(() => count, …)`.** Passing the value itself hands `watch` a number, which it can never see change.
- **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
+ </critical_requirements>
- **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
+ **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, InjectionKey, Suspense, toRefs, toValue, MaybeRefOrGetter
- **Key patterns covered:**
+ **Applies to:**
- - Script setup syntax and compiler macros (defineProps, defineEmits, defineExpose)
- - Reactivity primitives (ref, reactive, computed, watch, watchEffect)
- - Composables pattern for logic reuse
- - defineModel() for v-model binding (Vue 3.4+)
- - useTemplateRef(), useId(), onWatcherCleanup() (Vue 3.5+)
- - Reactive props destructure with getter requirement (Vue 3.5+)
- - Provide/Inject for dependency injection
- - Async components and Suspense
+ - Reactive state with `ref`, `reactive`, `computed`, `watch` and `watchEffect`
+ - Component contracts: props, emits, exposed methods, v-model
+ - Composables for reusable stateful logic
+ - Lifecycle and cleanup
+ - Provide/inject, async components and Suspense
- **When NOT to use:**
+ **Handled elsewhere:**
- - Components that don't benefit from logic extraction
- - When team has no Composition API experience (consider gradual adoption)
+ - Styling — a `<style scoped>` block is Vue's, and which CSS approach fills it is not settled here
+ - Application-wide state stores that outlive a component tree
+ - Server-state caching, invalidation and request deduplication
+ - Routing, and the data a route loads
+ - Test doubles for the network
---
<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.
-
- **Core principles:**
+ The Composition API groups code by the concern it serves rather than by the kind of thing it is. One feature's state, its derived values, its watcher and its cleanup sit together and can be lifted out whole into a composable — where the Options API scattered the same feature across `data`, `computed`, `methods` and `mounted`, and offered no way to move it.
- 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
+ That is what makes a composable the unit of reuse: it is a plain function that happens to call reactive primitives, so it composes, takes arguments and returns whatever shape suits — no mixin merge order, no name collisions.
</philosophy>
---
<patterns>
- ## Core Patterns
+ ## Core patterns
- ### Pattern 1: Script Setup with Props and Emits
+ ### Pattern 1: Script setup, props and emits
- All variables/functions in `<script setup>` are automatically available in the template. Use TypeScript generics with `defineProps` and `defineEmits` for type-safe interfaces.
+ `defineProps` and `defineEmits` take type arguments, so the contract is declared once and checked at both ends.
```vue
<script setup lang="ts">
- import { ref, computed } from "vue";
-
const props = defineProps<{
userId: string;
initialCount?: number;
}>();
const emit = defineEmits<{
update: [value: number];
submit: [];
}>();
const count = ref(props.initialCount ?? 0);
const doubleCount = computed(() => count.value * 2);
-
- function increment() {
- count.value++;
- emit("update", count.value);
- }
</script>
```
- **Why good:** No explicit return needed, TypeScript types flow naturally, named tuple emit syntax (Vue 3.3+) self-documents payloads
+ The named-tuple emit syntax (3.3+) documents each payload in the type itself.
- See [examples/core.md](examples/core.md) for a complete component with loading/error handling.
+ Full code: [examples/core.md](examples/core.md)
---
- ### Pattern 2: Reactivity - ref vs reactive
-
- `ref()` for primitives and reassignable values, `reactive()` for objects with nested properties. Access ref values via `.value` in script; templates unwrap automatically.
+ ### Pattern 2: ref and reactive
```typescript
- const count = ref(0); // Primitive -> ref
- count.value++; // .value in script
+ const count = ref(0);
+ count.value++;
const state = reactive({
- // Nested object -> reactive
user: null as User | null,
settings: { theme: "light" },
});
- state.settings.theme = "dark"; // Direct access, no .value
+ state.settings.theme = "dark";
```
- **Gotcha:** Destructuring `reactive()` loses reactivity - use `toRefs(state)` if you need to destructure.
+ `.value` in script, unwrapped in template. Destructuring a `reactive` object copies its values out of the proxy — `toRefs(state)` is what keeps them connected.
- See [examples/reactivity.md](examples/reactivity.md) for ref/reactive/computed patterns and anti-patterns.
+ Full code: [examples/reactivity.md](examples/reactivity.md)
---
- ### Pattern 3: Watch and WatchEffect
-
- **Skip if using Nuxt — use useFetch or useAsyncData instead.**
+ ### Pattern 3: watch and watchEffect
- `watch()` for explicit sources with access to old values. `watchEffect()` for automatic dependency tracking that runs immediately. Use `onWatcherCleanup()` (Vue 3.5+) to cancel stale async work.
+ `watch` names its source and gives you the previous value; `watchEffect` tracks whatever it reads and runs immediately. `onWatcherCleanup()` (3.5+) cancels work the next run supersedes.
```typescript
- // watch: explicit source, access to old value
watch(searchQuery, async (newQuery, oldQuery) => {
- /* ... */
+ /* … */
});
- // watchEffect: auto-tracks dependencies, runs immediately
watchEffect(async () => {
if (userId.value) userData.value = await fetchUser(userId.value);
});
- // Cleanup: cancel stale requests (Vue 3.5+)
watch(searchQuery, async (query) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort());
- const res = await fetch(`/api/search?q=${query}`, {
- signal: controller.signal,
- });
+ await fetch(`/api/search?q=${query}`, { signal: controller.signal });
});
```
- **Gotcha:** Watch reactive object properties with a getter: `watch(() => state.count, ...)` not `watch(state.count, ...)`.
+ A property of a `reactive` object is watched through a getter — `watch(() => state.count, …)`.
- See [examples/vue-3-5-features.md](examples/vue-3-5-features.md) for complete onWatcherCleanup patterns.
+ Full code: [examples/vue-3-5-features.md](examples/vue-3-5-features.md)
---
- ### Pattern 4: Lifecycle and Cleanup
-
- Always pair `onMounted` setup with `onUnmounted` cleanup. Timers, listeners, observers, WebSockets - anything opened must be closed.
+ ### Pattern 4: Lifecycle and cleanup
```typescript
const POLL_INTERVAL_MS = 5000;
let intervalId: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
intervalId = setInterval(fetchData, POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
});
```
- See [examples/lifecycle.md](examples/lifecycle.md) for WebSocket reconnection and event listener cleanup patterns.
+ Full code: [examples/lifecycle.md](examples/lifecycle.md)
---
### Pattern 5: Composables
- Extract reusable stateful logic into `use*` functions. Return objects with refs (not bare values) so destructuring preserves reactivity.
+ A composable returns an object of refs, so a caller can destructure it without flattening the reactivity.
```typescript
export function useCounter(options: UseCounterOptions = {}) {
- const { initialValue = 0, min = -Infinity, max = Infinity } = options;
+ const { initialValue = 0, max = Infinity } = options;
const count = ref(initialValue);
const isAtMax = computed(() => count.value >= max);
function increment() {
if (count.value < max) count.value++;
}
- function reset() {
- count.value = initialValue;
- }
- return { count, isAtMax, increment, reset }; // Return object with refs
+ return { count, isAtMax, increment };
}
```
- **Async composables** should accept `MaybeRefOrGetter<T>` inputs (use `toValue()` to normalize) and return `{ data, error, isLoading }` refs.
+ An async composable takes `MaybeRefOrGetter<T>` inputs, normalises them with `toValue()`, and returns `{ data, error, isLoading }` — so a caller can pass a ref, a getter or a plain value interchangeably.
- See [examples/composables.md](examples/composables.md) for useFetch, useLocalStorage, useDebounce, and useIntersectionObserver implementations.
+ Full code: [examples/composables.md](examples/composables.md)
---
- ### Pattern 6: defineModel for v-model (Vue 3.4+)
+ ### Pattern 6: defineModel for v-model (3.4+)
- Replaces the `defineProps` + `defineEmits` boilerplate for two-way binding. Returns a ref-like value that syncs with the parent.
+ Returns a ref that reads the prop and emits the update, replacing the `defineProps` + `defineEmits` pair the parent used to need.
```vue
<script setup lang="ts">
- const model = defineModel<string>(); // Single v-model
- const firstName = defineModel<string>("firstName"); // Named v-model
- const [model, modifiers] = defineModel<string>({
- // With modifiers
- set(value) {
- return modifiers.capitalize
- ? value.charAt(0).toUpperCase() + value.slice(1)
- : value;
+ const model = defineModel<string>();
+ const firstName = defineModel<string>("firstName");
+
+ const [value, modifiers] = defineModel<string>({
+ set(v) {
+ return modifiers.capitalize ? v.charAt(0).toUpperCase() + v.slice(1) : v;
},
});
</script>
```
- See [examples/vue-3-5-features.md](examples/vue-3-5-features.md) for complete defineModel examples with named models and modifiers.
+ Full code: [examples/vue-3-5-features.md](examples/vue-3-5-features.md)
---
- ### Pattern 7: Template Refs (Vue 3.5+)
+ ### Pattern 7: Template refs (3.5+)
- `useTemplateRef()` separates template refs from reactive refs. Use for dynamic ref names and in composables. Traditional `ref()` still works for simple static refs.
+ `useTemplateRef()` looks up a ref by its string name, which is what makes it work with a dynamic name and inside a composable. A plain `ref()` matching the attribute still works for a static one.
```vue
<script setup lang="ts">
const inputRef = useTemplateRef<HTMLInputElement>("myInput");
onMounted(() => inputRef.value?.focus());
</script>
<template>
<input ref="myInput" type="text" />
</template>
```
- **For child component refs:** Use `defineExpose()` to declare the public API, then `ref<InstanceType<typeof Child>>()` in the parent.
+ For a child component, `defineExpose()` declares its public surface, and the parent types the ref `InstanceType<typeof Child>`.
- See [examples/define-expose.md](examples/define-expose.md) for form validation with exposed methods and [examples/vue-3-5-features.md](examples/vue-3-5-features.md) for useTemplateRef in composables.
+ Full code: [examples/define-expose.md](examples/define-expose.md), [examples/vue-3-5-features.md](examples/vue-3-5-features.md)
---
- ### Pattern 8: useId for Accessible IDs (Vue 3.5+)
+ ### Pattern 8: useId for accessible ids (3.5+)
- Generates SSR-safe unique IDs for form labels and ARIA attributes. Each call produces a different ID. Must be called in setup (not in computed).
+ Generates an id that matches between server and client render, which is what a hand-rolled counter or a random string cannot do.
```vue
<script setup lang="ts">
const id = useId();
</script>
<template>
<label :for="id">Email</label>
<input :id="id" type="email" />
</template>
```
- See [examples/vue-3-5-features.md](examples/vue-3-5-features.md) for multi-field forms and ARIA patterns.
+ Each call returns a different id, so call it once per field in setup — never inside a `computed`.
+ Full code: [examples/vue-3-5-features.md](examples/vue-3-5-features.md)
+
---
- ### Pattern 9: Reactive Props Destructure (Vue 3.5+)
+ ### Pattern 9: Reactive props destructure (3.5+)
- Destructured props are automatically reactive. Use JavaScript default syntax instead of `withDefaults()`. The critical gotcha: destructured props require a getter wrapper in `watch()`.
+ Destructured props stay reactive, and JavaScript default syntax replaces `withDefaults()`.
```vue
<script setup lang="ts">
const {
title,
count = 0,
items = () => [],
} = defineProps<{
title: string;
count?: number;
items?: string[];
}>();
- // CORRECT: getter wrapper
watch(
() => count,
(newCount) => {
- /* ... */
+ /* … */
},
);
-
- // WRONG: passes value, not reactive source
- // watch(count, ...) // Never triggers!
</script>
```
- See [examples/vue-3-5-features.md](examples/vue-3-5-features.md) for complete reactive destructure examples.
+ The compiler rewrites each reference into a prop access, so a destructured name passed as a value — to `watch`, or into a function — is just the value at that instant. Hence the getter.
+ Full code: [examples/vue-3-5-features.md](examples/vue-3-5-features.md)
+
---
- ### Pattern 10: Provide/Inject
+ ### Pattern 10: Provide/inject
- Type-safe dependency injection to avoid prop drilling. Define `InjectionKey<T>` symbols in a separate file, provide in ancestor, inject in descendant with an explicit error for missing providers.
+ An `InjectionKey<T>` symbol carries the value's type from provider to consumer, so neither side casts.
```typescript
// injection-keys.ts
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol("theme");
- // Provider: provide(THEME_KEY, { theme, toggleTheme });
- // Consumer: const ctx = inject(THEME_KEY);
- // if (!ctx) throw new Error("Must be used within ThemeProvider");
+ // provider
+ provide(THEME_KEY, { theme, toggleTheme });
+
+ // consumer
+ const ctx = inject(THEME_KEY);
+ if (!ctx) throw new Error("Must be used within ThemeProvider");
```
- See [examples/provide-inject.md](examples/provide-inject.md) for a complete theme provider/consumer pattern.
+ Full code: [examples/provide-inject.md](examples/provide-inject.md)
---
- ### Pattern 11: Async Components and Suspense
+ ### Pattern 11: Async components and Suspense
- `defineAsyncComponent` for code-splitting. Top-level `await` in `<script setup>` makes a component async (requires `<Suspense>` in parent). Use `onErrorCaptured` at the Suspense boundary for error handling.
+ `defineAsyncComponent` code-splits at the component boundary. A top-level `await` in `<script setup>` makes the component async, which requires a `<Suspense>` above it.
```typescript
const LOADING_DELAY_MS = 200;
const LOAD_TIMEOUT_MS = 10000;
const HeavyChart = defineAsyncComponent({
- loader: () => import("@/components/HeavyChart.vue"),
+ loader: () => import("./components/heavy-chart.vue"),
loadingComponent: LoadingSpinner,
delay: LOADING_DELAY_MS,
timeout: LOAD_TIMEOUT_MS,
});
```
- See [examples/async.md](examples/async.md) for Suspense boundaries with error handling.
-
- </patterns>
-
- ---
+ `delay` is what stops the spinner flashing on a fast load. Errors from the boundary are caught with `onErrorCaptured`.
- **Detailed Resources:**
+ Full code: [examples/async.md](examples/async.md)
- - [examples/core.md](examples/core.md) - Complete component, template refs, focus management
- - [examples/reactivity.md](examples/reactivity.md) - ref, reactive, computed patterns and anti-patterns
- - [examples/composables.md](examples/composables.md) - useFetch, useLocalStorage, useDebounce, useIntersectionObserver
- - [examples/lifecycle.md](examples/lifecycle.md) - WebSocket, timers, event listeners, cleanup patterns
- - [examples/provide-inject.md](examples/provide-inject.md) - Theme provider, typed injection keys
- - [examples/define-expose.md](examples/define-expose.md) - Form field validation, parent-child coordination
- - [examples/vue-3-5-features.md](examples/vue-3-5-features.md) - defineModel, useTemplateRef, useId, onWatcherCleanup, reactive destructure, deferred Teleport
- - [examples/async.md](examples/async.md) - Lazy loading, Suspense, async setup
- - [reference.md](reference.md) - Decision frameworks, TypeScript patterns, anti-patterns, checklists
+ </patterns>
---
<red_flags>
- ## RED FLAGS
-
- **High Priority Issues:**
-
- - Missing cleanup in `onUnmounted` - timers, listeners, subscriptions, WebSockets cause memory leaks
- - Accessing `ref.value` in template - templates auto-unwrap refs, writing `.value` in templates is wrong
- - Destructuring `reactive()` without `toRefs()` - loses reactivity silently
- - Watching destructured prop directly - `watch(count, ...)` never triggers, use `watch(() => count, ...)`
+ ## Red flags
- **Medium Priority Issues:**
+ **Breaks at runtime:**
- - Watch without async cleanup - causes race conditions; use `onWatcherCleanup()` (3.5+) or the cleanup callback
- - Using `provide()` with string keys instead of typed `InjectionKey<T>` symbols - loses type safety
- - Returning bare values from composables instead of an object with refs - breaks destructuring reactivity
+ - A prop assigned to — `props.count++` warns and changes nothing, because props are read-only; emit an update and let the parent own the value
+ - A destructured prop watched directly — `watch(count, …)` receives a number and never fires; wrap it in a getter
+ - A `reactive` object destructured without `toRefs()` — the copies leave the proxy and stop updating
+ - A `reactive` variable reassigned — the template still holds the old proxy
+ - Setup opened without a matching `onUnmounted` — the timer, listener or socket survives the component
+ - `watch` running an async request with no cleanup — a slow earlier response overwrites a fast later one; `onWatcherCleanup()` (3.5+) or the cleanup callback settles the order
+ - `useId()` called inside a `computed` — it mints a new id per evaluation, so the label and the input drift apart
+ - `provide()` with a string key — the consumer gets `unknown`, and two features can collide on the same key
- **Gotchas & Edge Cases:**
+ **Surprising behaviour:**
- - Refs in reactive objects are auto-unwrapped at root level, but NOT in arrays or Map/Set
- - `watchEffect` runs immediately; `watch` is lazy by default
- - Computed values are read-only by default; use getter/setter object for writable computed
- - Top-level `await` makes a component async and requires `<Suspense>` in parent
- - Provide values are not reactive by default - wrap in `ref()` or `reactive()` if consumers need reactivity
- - `onUnmounted` won't run if component errors during setup - use error boundaries for critical cleanup
- - `useId()` must not be called in computed - it generates a new ID each call
- - `defineModel` returns a ref - use `.value` in script, auto-unwrapped in template
+ - `.value` written in a template is wrong; templates unwrap refs already
+ - Refs nested in a `reactive` object unwrap at the root, but not inside an array, a `Map` or a `Set`
+ - `watchEffect` runs immediately, `watch` does not until `immediate: true`
+ - A `computed` is read-only unless declared with a getter and a setter
+ - A provided value is not reactive on its own — wrap it in `ref()` or `reactive()` if consumers should see changes
+ - `onUnmounted` never runs for a component that threw during setup
+ - `defineModel` returns a ref, so it takes `.value` in script and none in the template
</red_flags>
-
- ---
-
- <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 wrap destructured props in a getter for `watch()` - `watch(() => count, ...)` not `watch(count, ...)`)**
-
- **Failure to follow these rules will cause memory leaks, broken reactivity, and unmaintainable component APIs.**
-
- </critical_reminders>