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.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add 0xDarkMatter/claude-mods --skill vue-ops --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Vue Ops?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/0xdarkmatter-vue-ops)More formats (shields.io, HTML) on the badges page.
---
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."
license: MIT
allowed-tools: "Read Write Bash"
metadata:
author: claude-mods
related-skills: typescript-ops, testing-ops, tailwind-ops, javascript-ops
---
# 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
```vue
<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+)
```vue
<!-- 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
```vue
<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+)
```vue
<script setup lang="ts">
defineOptions({
name: 'MyComponent',
inheritAttrs: false,
})
</script>
```
### `defineSlots` — type slots (Vue 3.3+)
```vue
<script setup lang="ts">
defineSlots<{
default(props: { item: User }): any
header(props: {}): any
}>()
</script>
```
---
## Pinia Quick Start
### Setup syntax (recommended — composable style)
```ts
// 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
```ts
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubled: (state) => state.count * 2,
},
actions: {
increment() { this.count++ },
},
})
```
### Using stores in components
```vue
<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
```ts
// 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
```ts
// 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
```ts
// 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' }
},
}
```
```vue
<!-- 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
```ts
// 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](./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) |
```vue
<!-- 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](https://vueuse.org/) — 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](./references/composition-api.md) | Composables, provide/inject, template refs, custom directives, Teleport, Suspense, slots, transitions, v-model deep patterns |
| [./references/state-routing.md](./references/state-routing.md) | Pinia advanced patterns (plugins, SSR, store composition), Vue Router (guards, meta typing, scroll behavior, transitions) |
| [./references/nuxt.md](./references/nuxt.md) | Nuxt 4 directory structure, data fetching, server routes, middleware, plugins, modules, SEO, deployment, Nuxt Content |
| [./references/testing.md](./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`](scripts/check-vue-facts.py)
guards them against silent drift — internal consistency in PR CI, live
major-version drift in the scheduled freshness job:
```bash
# 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`](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)
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!