Vue Operations
SkillAI & modelsVue 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.
No other account needed.
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
| 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)
Signals
- GitHub stars
- 36
- Forks
- 5
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
vue-ops- Source
- github.com/0xdarkmatter/claude-mods