Vue 3 Composition API skill for Codex
Script setup components that never lose reactivity. Codex writes Vue single-file components in script setup with typed props, emits, slots and defineModel, derives state with computed instead of syncing watchers, and builds composables that accept refs or getters, abort stale requests and clean up listeners. It knows the destructuring and watch-source traps that silently drop reactivity, and converts Options API code block by block under an existing test.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- vue-3-composition Installs to ~/.agents/skills/vue-3-composition/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install vue-3-composition The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project vue-3-composition Installs to .agents/skills/vue-3-composition/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/vue-3-composition.zipunzip -o vue-3-composition.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) vue-3-composition PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $vue-3-composition, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and refactors Vue 3.5 components with the Composition API and script setup - ref versus reactive, computed, watch and watchEffect cleanup, defineProps with reactive destructure, defineModel, typed emits and slots, useTemplateRef, and composables that accept refs or getters. Use when building Vue single-file components, extracting logic into a composable, converting Options API code, or debugging lost reactivity, watchers that never fire, or leaking listeners.
Use it when
- Writing or reviewing Vue 3 single-file components
- Moving shared logic out of mixins or components into composables
- State updates do not reach the template or a watcher
- Migrating Options API code to the Composition API
Use something else when
- The app is Nuxt and the question is data fetching, routing or server routes: use nuxt-4
- State must be shared across pages or persisted: use pinia-state
How to check the result
vue-tsc --noEmit passes, component tests pass unchanged after refactors, and composable tests show stale requests are aborted and listeners removed on unmount.
The mistake it prevents
Destructuring a reactive object or store and passing the plain values to watch, which silently cuts the reactive link.
Done well means: components use <script setup lang="ts">, state has one obvious owner,
derived values are computed (never synced with a watcher), every side effect cleans up,
and composables take MaybeRefOrGetter inputs so callers can pass plain values, refs or
getters without losing reactivity.
Before changing code
- Read
AGENTS.md,package.json(Vue version; 3.5+ adds reactive props destructure,useTemplateRef,useId,onWatcherCleanup) and the lint config (eslint-plugin-vuerules often enforce macro order or banreactive). - Look in
src/composables/(orcomposables/in Nuxt) before writing a new composable. Check whether VueUse is installed; it already hasuseEventListener,useDebounceFn,useLocalStorageand many more. - Check for Nuxt: if
nuxt.config.tsexists, auto-imports apply and data fetching rules differ (hand off to$nuxt-4).
Component shape
<script setup lang="ts">
import { computed, ref, watch, useTemplateRef, onMounted } from "vue";
import type { Product } from "@/types";
const { product, maxQty = 10 } = defineProps<{ product: Product; maxQty?: number }>();
const qty = defineModel<number>("qty", { required: true });
const emit = defineEmits<{ addToCart: [id: string, qty: number] }>();
defineSlots<{ default?(): any; badge?(props: { onSale: boolean }): any }>();
const input = useTemplateRef<HTMLInputElement>("qtyInput");
const total = computed(() => product.price * qty.value);
const canAdd = computed(() => qty.value > 0 && qty.value <= maxQty);
watch(() => product.id, () => { qty.value = 1; });
onMounted(() => input.value?.focus());
</script>
<template>
<slot name="badge" :on-sale="product.onSale" />
<input ref="qtyInput" v-model.number="qty" type="number" min="1" :max="maxQty" />
<p>{{ total.toFixed(2) }}</p>
<button :disabled="!canAdd" @click="emit('addToCart', product.id, qty)">Add</button>
</template>
Order inside <script setup>: imports, props, model, emits, slots, injected deps,
state, computed, watchers, lifecycle hooks, functions. Keep it consistent with the repo.
Decisions
| Situation | Use | Not |
|---|---|---|
| Primitive or value you replace wholesale | ref | reactive (cannot reassign) |
| Large immutable payload (API list, chart data) | shallowRef, replace .value | deep ref (proxies every node) |
| Group of fields always used together | ref({...}) or reactive | Destructuring a reactive (loses reactivity) |
| Value derived from other state | computed | watch that writes another ref |
| Derived value you sometimes override locally | writable computed with get/set, or ref + watch on the source | Mutating a prop |
| Side effect on specific sources | watch(source, cb) | watchEffect with hidden dependencies |
| Side effect on whatever it reads, run immediately | watchEffect | |
| Two-way binding with parent | defineModel | prop + update: emit by hand |
| DOM access | useTemplateRef("name") (3.5+) | ref named like the template ref (older pattern) |
| Deep tree dependency injection | provide/inject with an InjectionKey<T> | Prop drilling past 3 levels; global store for local state |
| Cross-page shared state | Pinia ($pinia-state) | Module-level ref in SSR apps (leaks between requests) |
Composables
// composables/useSearch.ts
import { ref, shallowRef, watch, toValue, onWatcherCleanup, type MaybeRefOrGetter } from "vue";
export function useSearch<T>(query: MaybeRefOrGetter<string>, fetcher: (q: string, s: AbortSignal) => Promise<T[]>) {
const results = shallowRef<T[]>([]); // replaced wholesale, no deep proxy
const error = ref<unknown>(null);
const loading = ref(false);
watch(
() => toValue(query),
async (q) => {
if (!q) { results.value = []; return; }
const ctrl = new AbortController();
onWatcherCleanup(() => ctrl.abort()); // cancels the previous request on change
loading.value = true;
error.value = null;
try {
results.value = await fetcher(q, ctrl.signal);
} catch (e) {
if (!ctrl.signal.aborted) error.value = e;
} finally {
if (!ctrl.signal.aborted) loading.value = false;
}
},
{ immediate: true },
);
return { results, error, loading };
}
Rules for composables:
- Name
useX, return an object of refs (not areactive), so callers can destructure. - Accept
MaybeRefOrGetterand read withtoValue()inside a watcher or computed. - Call only synchronously inside
setup(or another composable). After anawait, lifecycle hooks andinjectno longer know the current instance. - Register cleanup:
onScopeDisposefor listeners and timers (works in components and ineffectScope),onWatcherCleanupfor per-run cleanup. - No module-level mutable state unless the composable is deliberately a singleton and the app is client-only.
Reactivity traps
- Destructuring
reactive()or a store breaks the link. UsetoRefs(state)orstoreToRefs(store). - Destructuring props is reactive only in
<script setup>on 3.5+ when done directly in thedefinePropsstatement. Passing the destructured variable intowatch(foo, ...)passes a plain value; writewatch(() => foo, ...). watch(someReactive.count, ...)watches a number. Use a getter:watch(() => state.count, ...).watchon areactiveobject is deep implicitly; on arefof an object it is not. Use{ deep: true }ordeep: 1(3.5) deliberately; deep watching large trees is slow.- Array index assignment and
Map/Setmutations are reactive in Vue 3; replacing ashallowRef’s inner object without reassigning.valueis not. CalltriggerRefor reassign. - Refs auto-unwrap in templates and inside
reactiveobjects, but not inside arrays orMaps held inreactive(list[0].valueis still needed). - Async
setup(top-levelawaitin<script setup>) makes the component require<Suspense>up the tree; prefer loading inside a composable with aloadingref. v-ifandv-foron the same element:v-ifruns first in Vue 3 and cannot see the loop variable. Use a<template v-for>wrapper or a computed filtered list.- Use
keyonv-foritems with a stable id, never the index for editable lists.
Converting Options API
Map one block at a time and keep behaviour identical:
| Options API | Composition API |
|---|---|
data() | ref / reactive |
computed: {} | computed() |
watch: {} | watch(); immediate, deep carry over |
methods | plain functions |
props, emits | defineProps, defineEmits (type-based) |
mounted, beforeUnmount | onMounted, onBeforeUnmount |
this.$refs.x | useTemplateRef("x") |
this.$emit("update:modelValue") | defineModel() |
| mixins | composables (explicit inputs and returns, no name clashes) |
this.$nextTick | await nextTick() |
Write or keep a component test before converting; it must pass unchanged afterwards.
Verification
npx vue-tsc --noEmit # type-check SFCs, including props and emits
npx eslint . --ext .ts,.vue # if the repo uses eslint-plugin-vue
npx vitest run # component tests with @vue/test-utils
- For a composable, test it in isolation by calling it inside a tiny host component
(
mount(defineComponent({ setup() { return useSearch(q, fake) } }))) and assert that changingqaborts the previous request. - In Vue DevTools, confirm no component re-renders on unrelated state changes and that listeners disappear after unmount (Performance panel, event listener count).
Output
Report the components or composables changed, any public prop, emit or model changes (breaking for parents), and reactivity fixes with the before and after behaviour.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Vue 3 Composition API"
short_description: "Script setup components that never lose reactivity"
default_prompt: "$vue-3-composition Extract the search logic from this Vue component into a reusable composable" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.