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.

  • Frontend
  • $vue-3-composition
  • 174 lines
  • 2 files
  • ~1,950 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- vue-3-composition

Installs to ~/.agents/skills/vue-3-composition/ for every project on this machine.

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.

~/.agents/skills/vue-3-composition/SKILL.md

--- name: vue-3-composition description: Writes and refactors Vue 3.5 components with the Composition API and script setup - ref versus reactive, computed, watch and watchEffect cleanup,… license: MIT ---

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

  1. Read AGENTS.md, package.json (Vue version; 3.5+ adds reactive props destructure, useTemplateRef, useId, onWatcherCleanup) and the lint config (eslint-plugin-vue rules often enforce macro order or ban reactive).
  2. Look in src/composables/ (or composables/ in Nuxt) before writing a new composable. Check whether VueUse is installed; it already has useEventListener, useDebounceFn, useLocalStorage and many more.
  3. Check for Nuxt: if nuxt.config.ts exists, 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

SituationUseNot
Primitive or value you replace wholesalerefreactive (cannot reassign)
Large immutable payload (API list, chart data)shallowRef, replace .valuedeep ref (proxies every node)
Group of fields always used togetherref({...}) or reactiveDestructuring a reactive (loses reactivity)
Value derived from other statecomputedwatch that writes another ref
Derived value you sometimes override locallywritable computed with get/set, or ref + watch on the sourceMutating a prop
Side effect on specific sourceswatch(source, cb)watchEffect with hidden dependencies
Side effect on whatever it reads, run immediatelywatchEffect
Two-way binding with parentdefineModelprop + update: emit by hand
DOM accessuseTemplateRef("name") (3.5+)ref named like the template ref (older pattern)
Deep tree dependency injectionprovide/inject with an InjectionKey<T>Prop drilling past 3 levels; global store for local state
Cross-page shared statePinia ($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 a reactive), so callers can destructure.
  • Accept MaybeRefOrGetter and read with toValue() inside a watcher or computed.
  • Call only synchronously inside setup (or another composable). After an await, lifecycle hooks and inject no longer know the current instance.
  • Register cleanup: onScopeDispose for listeners and timers (works in components and in effectScope), onWatcherCleanup for 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. Use toRefs(state) or storeToRefs(store).
  • Destructuring props is reactive only in <script setup> on 3.5+ when done directly in the defineProps statement. Passing the destructured variable into watch(foo, ...) passes a plain value; write watch(() => foo, ...).
  • watch(someReactive.count, ...) watches a number. Use a getter: watch(() => state.count, ...).
  • watch on a reactive object is deep implicitly; on a ref of an object it is not. Use { deep: true } or deep: 1 (3.5) deliberately; deep watching large trees is slow.
  • Array index assignment and Map/Set mutations are reactive in Vue 3; replacing a shallowRef’s inner object without reassigning .value is not. Call triggerRef or reassign.
  • Refs auto-unwrap in templates and inside reactive objects, but not inside arrays or Maps held in reactive (list[0].value is still needed).
  • Async setup (top-level await in <script setup>) makes the component require <Suspense> up the tree; prefer loading inside a composable with a loading ref.
  • v-if and v-for on the same element: v-if runs first in Vue 3 and cannot see the loop variable. Use a <template v-for> wrapper or a computed filtered list.
  • Use key on v-for items with a stable id, never the index for editable lists.

Converting Options API

Map one block at a time and keep behaviour identical:

Options APIComposition API
data()ref / reactive
computed: {}computed()
watch: {}watch(); immediate, deep carry over
methodsplain functions
props, emitsdefineProps, defineEmits (type-based)
mounted, beforeUnmountonMounted, onBeforeUnmount
this.$refs.xuseTemplateRef("x")
this.$emit("update:modelValue")defineModel()
mixinscomposables (explicit inputs and returns, no name clashes)
this.$nextTickawait 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 changing q aborts 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.yamlgenerated from this page

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.

esc