Agent Skills: Vue Operations

Vue 3 development patterns, Composition API, Pinia state management, Vue Router, and Nuxt 4. Use for: vue, vuejs, composition api, pinia, vue router, nuxt, nuxt4, nuxt3, script setup, composable, reactive, defineProps, defineEmits, defineModel, v-model, provide inject, vue3.

UncategorizedID: 0xdarkmatter/claude-mods/vue-ops

Install this agent skill to your local

pnpm dlx add-skill https://github.com/0xDarkMatter/claude-mods/tree/HEAD/skills/vue-ops

Skill Files

Browse the full folder contents for vue-ops.

Download Skill

Loading file tree…

skills/vue-ops/SKILL.md

Skill Metadata

Name
vue-ops
Description
"Vue 3 development patterns, Composition API, Pinia state management, Vue Router, and Nuxt 4. Use for: vue, vuejs, composition api, pinia, vue router, nuxt, nuxt4, nuxt3, script setup, composable, reactive, defineProps, defineEmits, defineModel, v-model, provide inject, vue3."

Vue Operations

Comprehensive Vue 3 reference covering Composition API, Pinia, Vue Router, Nuxt 4, and testing — production patterns with TypeScript throughout.

Vue 3 / Nuxt 4 ecosystem facts verified as of 2026-07-05.


Reactivity Decision Tree

What data do I need to make reactive?
│
├─ A single primitive (string, number, boolean)?
│   └─ ref()
│       const count = ref(0)
│       const name = ref('')
│
├─ A plain object or array with deep reactivity?
│   ├─ Will I destructure it or pass properties individually?
│   │   └─ reactive() — but use toRefs() when destructuring
│   └─ Will I replace the whole object at once?
│       └─ ref() — ref.value = newObject
│
├─ Derived/computed state from other reactive sources?
│   └─ computed()
│       const doubled = computed(() => count.value * 2)
│
├─ A large object where only top-level keys change?
│   └─ shallowRef() or shallowReactive()
│       const state = shallowRef({ nested: { big: 'data' } })
│
├─ Side effects that should run when dependencies change?
│   ├─ Don't need to know old value, auto-tracks dependencies?
│   │   └─ watchEffect(() => { ... })
│   └─ Need old/new values, explicit sources, or lazy execution?
│       └─ watch(source, (newVal, oldVal) => { ... })
│
└─ Data that should NOT be reactive (raw DOM, third-party instances)?
    └─ markRaw(obj) or shallowRef(obj)

Component Communication Decision Tree

How far does data need to travel?
│
├─ Parent → direct child?
│   └─ props (defineProps)
│       Direct, explicit, type-safe
│
├─ Child → parent (user action / data update)?
│   └─ emit (defineEmits)
│       defineEmits<{ change: [value: string] }>()
│
├─ Parent ↔ child bidirectional binding?
│   └─ v-model via defineModel() (Vue 3.4+)
│       const model = defineModel<string>()
│
├─ Ancestor → deep descendant (prop drilling problem)?
│   └─ provide / inject
│       Use InjectionKey<T> for type safety
│
├─ Siblings or unrelated components?
│   ├─ Simple/few shared values?
│   │   └─ provide / inject from a common ancestor
│   └─ Complex shared state or cross-tree communication?
│       └─ Pinia store
│
├─ Truly global state (user session, cart, preferences)?
│   └─ Pinia store
│       defineStore with setup syntax
│
└─ One-time events between distant components (rare)?
    └─ Pinia action + watch, or mitt event bus
        Avoid: Vue removed $emit on root in Vue 3

Composition API Quick Reference

<script setup> — the standard

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'

// Props — with TypeScript generics (no runtime declaration needed)
const props = defineProps<{
  title: string
  count?: number
}>()

// Props with defaults
const props = withDefaults(defineProps<{
  size: 'sm' | 'md' | 'lg'
  disabled?: boolean
}>(), {
  size: 'md',
  disabled: false,
})

// Emits — type-safe event signatures
const emit = defineEmits<{
  change: [value: string]        // named tuple syntax (Vue 3.3+)
  update: [id: number, data: object]
  close: []
}>()

// Reactive state
const count = ref(0)
const user = reactive({ name: '', email: '' })

// Computed
const doubled = computed(() => count.value * 2)

// Watch
watch(count, (newVal, oldVal) => {
  console.log(`count changed from ${oldVal} to ${newVal}`)
})

// Lifecycle
onMounted(() => {
  console.log('component mounted')
})
</script>

defineModel — v-model binding (Vue 3.4+)

<!-- Child component: MyInput.vue -->
<script setup lang="ts">
const model = defineModel<string>({ required: true })

// Named v-model: <MyInput v-model:title="..." />
const title = defineModel<string>('title')

// With modifiers
const [modelValue, modifiers] = defineModel<string, 'trim' | 'uppercase'>()
</script>

<template>
  <input :value="model" @input="model = $event.target.value" />
</template>

defineExpose — expose to parent refs

<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)

function focus() {
  inputRef.value?.focus()
}

// Expose public API for parent template refs
defineExpose({ focus })
</script>

defineOptions — component meta (Vue 3.3+)

<script setup lang="ts">
defineOptions({
  name: 'MyComponent',
  inheritAttrs: false,
})
</script>

defineSlots — type slots (Vue 3.3+)

<script setup lang="ts">
defineSlots<{
  default(props: { item: User }): any
  header(props: {}): any
}>()
</script>

Pinia Quick Start

Setup syntax (recommended — composable style)

// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  const name = ref('Counter')

  // getters
  const doubled = computed(() => count.value * 2)

  // actions
  function increment() {
    count.value++
  }

  async function fetchData() {
    const data = await api.get('/data')
    count.value = data.total
  }

  return { count, name, doubled, increment, fetchData }
})

Options syntax

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubled: (state) => state.count * 2,
  },
  actions: {
    increment() { this.count++ },
  },
})

Using stores in components

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const store = useCounterStore()

// storeToRefs preserves reactivity when destructuring state/getters
// Actions can be destructured directly (they're not reactive)
const { count, doubled } = storeToRefs(store)
const { increment } = store
</script>

Pinia plugins — persistence example

// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// In store:
export const useAuthStore = defineStore('auth', () => { ... }, {
  persist: true, // or { storage: sessionStorage, paths: ['token'] }
})

Vue Router Quick Reference

Basic configuration

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: () => import('@/views/HomeView.vue'), // lazy load
    },
    {
      path: '/users/:id',
      name: 'user',
      component: () => import('@/views/UserView.vue'),
      props: true,                    // passes :id as prop
      meta: { requiresAuth: true },
    },
    {
      path: '/admin',
      component: () => import('@/layouts/AdminLayout.vue'),
      children: [
        { path: '', component: () => import('@/views/admin/Dashboard.vue') },
        { path: 'users', component: () => import('@/views/admin/Users.vue') },
      ],
    },
    { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound },
  ],
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition
    if (to.hash) return { el: to.hash, behavior: 'smooth' }
    return { top: 0 }
  },
})

export default router

Navigation guards

// Global guard — auth check
router.beforeEach((to, from) => {
  const auth = useAuthStore()
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
})

// Per-route guard
{
  path: '/admin',
  beforeEnter: (to, from) => {
    if (!isAdmin()) return { name: 'forbidden' }
  },
}
<!-- In-component guard -->
<script setup lang="ts">
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

onBeforeRouteLeave((to, from) => {
  if (hasUnsavedChanges.value) {
    return confirm('Leave without saving?')
  }
})
</script>

TypeScript meta typing

// router/index.ts — augment RouteMeta
declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    title?: string
    breadcrumb?: string
  }
}

Nuxt 4 Decision Tree

Nuxt 4's flagship change over Nuxt 3 is the app/ source directory (app code separated from server/ and root config — see ./references/nuxt.md); the rendering strategies below are unchanged.

What rendering strategy does my app need?
│
├─ Public content (blogs, marketing, docs)?
│   ├─ Content rarely changes (< daily)?
│   │   └─ SSG — prerender: { routes: ['/', '/about'] }
│   └─ Content updated frequently?
│       └─ ISR — routeRules: { '/blog/**': { isr: 3600 } }
│
├─ Dynamic per-user content (dashboards, apps)?
│   └─ SSR — ssr: true (Nuxt default)
│       Best for SEO + authenticated data
│
├─ Admin panel / internal tool (no SEO needed)?
│   └─ SPA — ssr: false in nuxt.config.ts
│
├─ Mixed needs (marketing pages + app)?
│   └─ Hybrid — routeRules per path
│       routeRules: {
│         '/': { prerender: true },
│         '/blog/**': { isr: 3600 },
│         '/app/**': { ssr: true },
│         '/admin/**': { ssr: false },
│       }
│
└─ Deploying to...
    ├─ Cloudflare Workers/Pages → preset: 'cloudflare'
    ├─ Vercel → preset: 'vercel' (auto-detected)
    ├─ Netlify → preset: 'netlify' (auto-detected)
    └─ Node.js server → preset: 'node-server'

Rendering Performance Quick Wins

| Technique | When to Use | |-----------|-------------| | v-memo="[dep1, dep2]" | Skip re-rendering a subtree (usually a v-for row) unless listed deps changed — only for measured hot lists | | <KeepAlive> | Cache component instances across tab/route switches; pair with onActivated/onDeactivated for refresh logic | | Virtual scrolling | Lists with hundreds+ of rows — vue-virtual-scroller or @tanstack/vue-virtual render only visible items | | shallowRef / markRaw | Large objects or third-party instances that don't need deep reactivity (see Reactivity Decision Tree) |

<!-- v-memo: row re-renders only when item.id or selection state changes -->
<div
  v-for="item in list"
  :key="item.id"
  v-memo="[item.id, item.id === selectedId]"
>
  {{ item.name }} — {{ item.id === selectedId ? 'selected' : '' }}
</div>

Tip: before writing a composable, check VueUse — 200+ battle-tested composables (useLocalStorage, useIntersectionObserver, useDark, ...) that handle SSR and cleanup edge cases.

Common Gotchas

| Gotcha | Why | Fix | |--------|-----|-----| | Reactivity lost after destructuring reactive() | Destructuring extracts plain values, not refs | Use toRefs(state) when destructuring, or use ref() instead of reactive() | | ref.value needed in <script>, not in <template> | Template auto-unwraps top-level refs | Access as count in template, count.value in script | | watch doesn't fire on nested object changes | Default is shallow watch | Add { deep: true } or watch a specific nested path () => obj.nested.prop | | Async setup breaks SSR in Nuxt | await in setup() suspends the component | Use useAsyncData or useFetch — never raw await fetch() in Nuxt setup | | watchEffect runs immediately and tracks lazily | Tracks dependencies at runtime, not statically | Use watch with explicit sources when you need control over what's tracked | | Template refs are null before mount | ref() is null until component is mounted | Access template refs inside onMounted or use watch with { immediate: false } | | Pinia store state lost when destructuring | State properties are not reactive when pulled out directly | Always use storeToRefs(store) for state/getters; destructure actions directly | | Props are readonly — mutating causes warning | Vue enforces one-way data flow | Emit event to parent and let parent update; or use defineModel() for two-way binding | | computed setter not called on direct assignment | Computed with no setter is read-only by default | Define get and set: computed({ get: () => ..., set: (v) => ... }) | | v-model on component uses wrong prop/event name | Default v-model uses modelValue prop and update:modelValue event | Use defineModel() (Vue 3.4+) or manually wire modelValue prop + update:modelValue emit | | provide value is not reactive | Providing a raw value instead of a ref | Provide ref() or reactive() so injectors see updates: provide('key', ref(value)) | | defineAsyncComponent error not caught | Async component rejects without error boundary | Add errorComponent option or wrap in <Suspense> with error slot |


Reference Files

| File | When to Load | |------|-------------| | ./references/composition-api.md | Composables, provide/inject, template refs, custom directives, Teleport, Suspense, slots, transitions, v-model deep patterns | | ./references/state-routing.md | Pinia advanced patterns (plugins, SSR, store composition), Vue Router (guards, meta typing, scroll behavior, transitions) | | ./references/nuxt.md | Nuxt 4 directory structure, data fetching, server routes, middleware, plugins, modules, SEO, deployment, Nuxt Content | | ./references/testing.md | Vitest setup, Vue Test Utils, Pinia/Router testing, composable testing, MSW, Playwright, Nuxt test utils |

Staleness Verifier

This skill encodes fast-moving facts (the Vue 3 minor-version gates, the Nuxt 4 meta-framework, the ecosystem package stack). scripts/check-vue-facts.py guards them against silent drift — internal consistency in PR CI, live major-version drift in the scheduled freshness job:

# Structural (PR CI, no network): every catalogued package + Vue version gate is
# still named in this skill's prose, and the currency note still carries a year.
python3 skills/vue-ops/scripts/check-vue-facts.py --offline        # exit 0 consistent, 10 drift

# Live (weekly freshness job, never blocks a PR): is any documented major
# now behind npm's latest dist-tag? (e.g. Nuxt 5 while the prose says Nuxt 4.)
python3 skills/vue-ops/scripts/check-vue-facts.py --live           # exit 10 a major moved ahead, 7 npm unreachable

The canonical fact list lives in assets/vue-facts.json; when you add or drop a recommendation or the prose stops naming one, update it to match or --offline fails CI.


See Also

  • typescript-ops — TypeScript generics, utility types, strict mode configuration
  • testing-ops — General testing patterns, TDD, mocking strategies, CI integration
  • tailwind-ops — Tailwind CSS with Vue component patterns, dark mode, responsive design
  • javascript-ops — Modern JS patterns used alongside Vue (async/await, modules, iterators)