Vue Operations

SkillAI & models

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Vue Operations skill

What this skill tells your AI

The instructions your AI receives, as published by 0xdarkmatter/claude-mods in skills/vue-ops/SKILL.md and read by ahel’s review.

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

TechniqueWhen 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 scrollingLists with hundreds+ of rows — vue-virtual-scroller or @tanstack/vue-virtual render only visible items
shallowRef / markRawLarge 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

GotchaWhyFix
Reactivity lost after destructuring reactive()Destructuring extracts plain values, not refsUse toRefs(state) when destructuring, or use ref() instead of reactive()
ref.value needed in <script>, not in <template>Template auto-unwraps top-level refsAccess as count in template, count.value in script
watch doesn't fire on nested object changesDefault is shallow watchAdd { deep: true } or watch a specific nested path () => obj.nested.prop
Async setup breaks SSR in Nuxtawait in setup() suspends the componentUse useAsyncData or useFetch — never raw await fetch() in Nuxt setup
watchEffect runs immediately and tracks lazilyTracks dependencies at runtime, not staticallyUse watch with explicit sources when you need control over what's tracked
Template refs are null before mountref() is null until component is mountedAccess template refs inside onMounted or use watch with { immediate: false }
Pinia store state lost when destructuringState properties are not reactive when pulled out directlyAlways use storeToRefs(store) for state/getters; destructure actions directly
Props are readonly — mutating causes warningVue enforces one-way data flowEmit event to parent and let parent update; or use defineModel() for two-way binding
computed setter not called on direct assignmentComputed with no setter is read-only by defaultDefine get and set: computed({ get: () => ..., set: (v) => ... })
v-model on component uses wrong prop/event nameDefault v-model uses modelValue prop and update:modelValue eventUse defineModel() (Vue 3.4+) or manually wire modelValue prop + update:modelValue emit
provide value is not reactiveProviding a raw value instead of a refProvide ref() or reactive() so injectors see updates: provide('key', ref(value))
defineAsyncComponent error not caughtAsync component rejects without error boundaryAdd errorComponent option or wrap in <Suspense> with error slot

Reference Files

FileWhen to Load
./references/composition-api.mdComposables, provide/inject, template refs, custom directives, Teleport, Suspense, slots, transitions, v-model deep patterns
./references/state-routing.mdPinia advanced patterns (plugins, SSR, store composition), Vue Router (guards, meta typing, scroll behavior, transitions)
./references/nuxt.mdNuxt 4 directory structure, data fetching, server routes, middleware, plugins, modules, SEO, deployment, Nuxt Content
./references/testing.mdVitest 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)

Signals

GitHub stars
36
Forks
5
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
vue-ops
Source
github.com/0xdarkmatter/claude-mods