Vue Composition API skill for Claude Code
Refs that stay reactive and composables that compose. Claude writes Vue 3 single-file components with typed macros, ref as the default state primitive and computed for everything derived. It catches the traps that silently stop updates — destructured reactive objects and stores, watched values instead of getters, hooks after await — and keeps server data out of Pinia, with Nuxt 4 specifics for useFetch keys and SSR-safe state.
- Name
vue-composition- Size
- 201 lines · ≈2,100 tokens
- Files
- 1
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and reviews Vue 3.5+ code with the Composition API and script setup — typed defineProps/defineEmits/defineModel, composables, Pinia setup stores, watchers with cleanup, and the reactivity traps that silently break updates. Includes Nuxt 4 data fetching notes (useFetch, useAsyncData, useState). Use when building .vue single-file components, extracting composables, migrating from the Options API or Vuex, debugging a value that does not update, or working in a Nuxt app. Try asking
- This computed never updates when the store changes — why?
- Convert this Options API component and its mixin to script setup and a composable
- Move our Vuex cart module to a Pinia setup store with types
Use it when
- Writing or reviewing .vue files with script setup
- A value in the template stops reacting to changes
- Extracting shared logic into composables
- Migrating Options API, mixins or Vuex to Composition API and Pinia
Skip it when
- The app is Svelte or SvelteKit — use sveltekit-apps
- You need the general choice of where state should live — use state-management
How to check the result
vue-tsc and eslint-plugin-vue pass, Vue Test Utils tests cover emits and v-model, and DevTools shows the expected state updating on interaction.
The common mistake it prevents
Destructuring a Pinia store or reactive object into local variables, which copies the values once and breaks reactivity without any error.
Good Vue code keeps reactive state minimal and explicit (ref by default), derives
the rest with computed, isolates reusable logic in typed composables that accept
refs or getters, and never loses reactivity by destructuring or replacing objects.
Before changing anything
npm ls vue pinia nuxt vue-router # 3.5+ enables reactive props destructure, useTemplateRef, useId
npx vue-tsc --noEmit # baseline type errors
npx eslint . --ext .vue,.ts # eslint-plugin-vue findings
Check whether the project uses <script setup lang="ts"> everywhere or mixes the
Options API. Match the existing style inside a file; migrate a file fully or not at all.
Workflow
- [ ] 1. Identify the source state (props, local refs, store, server data)
- [ ] 2. Express everything else as computed
- [ ] 3. Type props/emits/models with the type-based macros
- [ ] 4. Extract logic used by 2+ components into a composable
- [ ] 5. Put cross-page client state in a Pinia setup store; server data in a fetch layer
- [ ] 6. Add watchers only for side effects, with cleanup
- [ ] 7. vue-tsc, lint, component test
Component template
<script setup lang="ts">
import { computed, useTemplateRef, onMounted } from "vue";
const { items, pageSize = 20 } = defineProps<{ // reactive destructure (3.5+)
items: Item[];
pageSize?: number;
}>();
const emit = defineEmits<{
select: [id: string];
loadMore: [];
}>();
const query = defineModel<string>("query", { default: "" }); // v-model:query
const filtered = computed(() =>
items.filter((i) => i.name.toLowerCase().includes(query.value.toLowerCase())),
);
const searchInput = useTemplateRef<HTMLInputElement>("search");
onMounted(() => searchInput.value?.focus());
</script>
<template>
<input ref="search" v-model="query" type="search" aria-label="Filter items" />
<ul>
<li v-for="item in filtered.slice(0, pageSize)" :key="item.id">
<button type="button" @click="emit('select', item.id)">{{ item.name }}</button>
</li>
</ul>
</template>
- Before 3.5, destructuring
defineProps()loses reactivity; useprops.itemsortoRefs(props)instead. - Use
defineModelinstead of a manualmodelValueprop +update:modelValueemit. - Never mutate a prop (including nested fields of an object prop). Emit an event or use
defineModel.
ref vs reactive
| Situation | Use |
|---|---|
| Default for any state | ref() — works for primitives and objects, survives reassignment and destructuring into composable returns |
| Large immutable data you replace wholesale (API payload, chart data) | shallowRef() and replace .value |
| Form object you mutate field-by-field and never reassign | reactive() is acceptable |
| Third-party instance (map, editor, class) | markRaw() or shallowRef() to avoid deep proxying |
Reactivity traps
| Symptom | Cause | Fix |
|---|---|---|
| Value stops updating after destructure | const { count } = reactive(state) copies the value | toRefs(state) or keep state.count |
| Store fields not reactive in component | Destructured Pinia store | const { count } = storeToRefs(store); actions destructure directly |
| Whole object replaced, UI stale | state = reactive({...}) reassigns the variable | Use ref and assign .value, or Object.assign(state, next) |
watch(state.count, ...) warns / never fires | Passing a value, not a source | watch(() => state.count, ...) |
| Watcher fires too often | watch(reactiveObj) is implicitly deep | Watch a getter of the specific field |
| Ref inside an array/Map not unwrapped in template | Auto-unwrap only applies to top-level refs | Access .value or restructure |
Lifecycle hook registered after await in setup is ignored | Current instance lost after the first await | Register hooks before any await |
| DOM not updated yet when reading | Updates are batched | await nextTick() |
Computed and watchers
computedmust be pure — no fetches, no mutations, no emits. If you want a side effect when something changes, that is a watcher.- Prefer
watchwith an explicit source overwatchEffectwhen the dependency set matters for correctness. - Cancel stale async work in watchers:
import { watch, onWatcherCleanup } from "vue";
watch(() => route.params.id, async (id) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort()); // 3.5+; older: third arg onCleanup
user.value = await fetchUser(id as string, { signal: controller.signal });
}, { immediate: true });
watch(..., { once: true })replaces manual unwatch-after-first-run.- Do not watch a prop to copy it into local state; derive with
computed, or use a writable computed /defineModelwhen the child edits it.
Composables
// composables/useDebounced.ts
import { ref, watch, toValue, onScopeDispose, type MaybeRefOrGetter, type Ref } from "vue";
export function useDebounced<T>(source: MaybeRefOrGetter<T>, ms = 300) {
const debounced = ref(toValue(source)) as Ref<T>;
let t: ReturnType<typeof setTimeout> | undefined;
watch(() => toValue(source), (v) => {
clearTimeout(t);
t = setTimeout(() => (debounced.value = v), ms);
});
onScopeDispose(() => clearTimeout(t));
return debounced;
}
Conventions:
- Name
useX, acceptMaybeRefOrGetterinputs and read them withtoValue(), so callers can pass a ref, a getter, or a plain value. - Return an object of refs (not a
reactive) so callers can destructure. - Clean up listeners, timers, and observers with
onScopeDispose(works inside and outside components). - Check VueUse before writing a generic composable (
useEventListener,useLocalStorage,useIntersectionObserver).
Pinia
Prefer setup stores; they are composables with devtools support:
export const useCartStore = defineStore("cart", () => {
const lines = ref<CartLine[]>([]);
const total = computed(() => lines.value.reduce((s, l) => s + l.priceCents * l.qty, 0));
function add(line: CartLine) { lines.value.push(line); }
function $reset() { lines.value = []; } // setup stores need a manual $reset
return { lines, total, add, $reset };
});
- Store only client state shared across routes (cart, UI preferences, auth session
shape). Server data belongs in a fetch/cache layer (
useFetch, TanStack Vue Query). - Return every piece of state from a setup store, or SSR hydration and devtools miss it.
- In SSR, never create module-level
refs for per-user state; they leak between requests. Use Pinia or NuxtuseState.
Nuxt 4 notes
- Source lives in
app/by default (app/pages,app/components); server routes inserver/api/*.tsviadefineEventHandler. useFetch(url)/useAsyncData(key, fn)dedupe between SSR and hydration. GiveuseAsyncDataa stable unique key; reusing keys across different fetchers shares data.- Nuxt 4 returns
dataas a shallow ref withundefineddefault; passdeep: trueonly if you mutate nested fields. - Use
$fetchinside event handlers and actions;useFetchonly in setup. useState("key", () => init)for SSR-safe shared state without Pinia.- Refresh with
refresh()from the composable orrefreshNuxtData(key)after mutations.
Gotchas
v-ifhas higher precedence thanv-foron the same element in Vue 3; filter in a computed instead of combining them.:keyonv-formust be stable IDs, never the index for editable lists.v-htmlwith user content needs sanitization (DOMPurify).- Emitting a native event name (
click) from a component without declaring it indefineEmitsproduces double handlers via attribute fallthrough. definePropsdefaults for objects/arrays in the non-destructure form need factory functions:withDefaults(defineProps<P>(), { tags: () => [] }).
Verification
npx vue-tsc --noEmitandnpx eslint . --ext .vue,.tspass.- Component test with Vitest +
@vue/test-utils(mount,setValue,trigger,emitted()) covers emits and v-model. - In Vue DevTools, the state you expect to be reactive updates in the inspector when interacting; Pinia stores show actions in the timeline.
- For Nuxt,
nuxi build && node .output/server/index.mjs, load a page with JS disabled for SSR output and watch the console for hydration mismatch warnings.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.