Skip to content
Back to skills

Vue3 Patterns

ASecurity

Vue 3 idioms — Composition API, `<script setup>`, composables, Pinia stores, reactivity gotchas, performance, and Volar / vue-tsc strictness. Auto-fires for `*.vue` and Vue-specific TypeScript files alongside `frontend-patterns` to add Vue depth the generic skill doesn't cover.

  • 12 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 28, 2026
developmenttypescriptgoreactvueangularrefactoringcode-reviewapifrontendsecurity

Works with

  • api

Security analysis

A100/100

Scanned September 28, 2026

npx -y skills add Nmor/the-claude-council --skill vue3-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Vue3 Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Vue3 Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/nmor-vue3-patterns/badge)](https://www.skillsdirectory.com/skills/nmor-vue3-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

SKILL.md
---
name: vue3-patterns
description: Vue 3 idioms — Composition API, `<script setup>`, composables, Pinia stores, reactivity gotchas, performance, and Volar / vue-tsc strictness. Auto-fires for `*.vue` and Vue-specific TypeScript files alongside `frontend-patterns` to add Vue depth the generic skill doesn't cover.
---

# Vue 3 Patterns

> **Reuse-first** (per `~/.claude/rules-library/common/reuse-first.md`):
> Before creating any new component, composable, store, or
> utility, sweep `components/`, `components/ui/`, `composables/`,
> `stores/`, `lib/`, `utils/`. One source of truth per primitive
> (one `<BaseButton>`, one `<DescriptionEditor>`, one
> `useApi()`, one `useToast()`). Extend with a prop or option —
> never fork. Compositional helpers (renderless components,
> `provide` / `inject`, slot-based APIs) are the canonical
> mechanism for extending shared primitives in Vue.
>
> **Size budget: 21 KB** — `token-budget.mjs --check`.

Vue 3 patterns that go beyond the generic `frontend-patterns` skill. Use
when the question is "how should this Vue component / composable / store be
shaped" — reactivity correctness, composable design, Pinia layout, and the
performance footguns specific to Vue's reactivity system.

## When to Activate

- Authoring or refactoring `.vue` files
- Designing a composable (`useFoo`)
- Adding or restructuring a Pinia store
- Reviewing a Vue PR for reactivity / performance bugs
- Tuning `vue-tsc` strictness or Volar config

## Default to `<script setup lang="ts">` + Composition API

Options API (`data() {}`, `methods: {}`) is fine for tiny demos and
discouraged for production code. Composition API + `<script setup>` is the
default because it composes naturally, plays well with TypeScript, and
eliminates `this`-binding bugs.

```vue
<script setup lang="ts">
import { ref, computed } from "vue";

const props = defineProps<{ userId: string }>();
const emit = defineEmits<{ saved: [userId: string] }>();

const draft = ref("");
const isValid = computed(() => draft.value.length > 0);

function save() {
  if (!isValid.value) return;
  emit("saved", props.userId);
}
</script>
```

Type-only `defineProps` / `defineEmits` give you better inference and
auto-complete than the runtime-array form.

## ref vs reactive: Default To `ref`

| API | Use when | Watch out for |
| --- | -------- | ------------- |
| `ref<T>` | Default for all reactive state | Always `.value` outside templates |
| `reactive<T>` | A bag of related fields you want to mutate by property | Destructuring breaks reactivity (`const { foo } = state`) |
| `shallowRef<T>` | Large objects you replace wholesale (D3 datasets, mxGraph models) | Inner mutations not tracked — replace the ref, don't mutate |
| `readonly<T>` | Public surface of a composable that owns the state | — |

Default to `ref`. Only reach for `reactive` when you've felt the pain of
`.value` more than the pain of "wait, why isn't this updating after I
destructured it."

## Composables: One File, Returns A Plain Object

A composable is a function starting with `use`, returning a plain object
of refs / computed / functions. Conventions:

```ts
// composables/useTask.ts
export function useTask(taskId: Ref<string>) {
  const task = ref<Task | null>(null);
  const loading = ref(false);

  async function load() {
    loading.value = true;
    task.value = await api.getTask(taskId.value);
    loading.value = false;
  }

  // Re-load when the input ref changes
  watch(taskId, load, { immediate: true });

  return { task: readonly(task), loading: readonly(loading), reload: load };
}
```

Rules:

- The composable owns its state — return `readonly` refs so callers can't
  mutate them by accident.
- Take refs as inputs, not raw values, when the input may change.
- Don't call composables conditionally — they must run unconditionally
  during component setup so cleanup hooks register.
- One file per composable. Easier to find, easier to test.

## Pinia Stores: `defineStore` With Setup Syntax

Use the setup-function form for parity with `<script setup>` ergonomics:

```ts
export const useAuthStore = defineStore("auth", () => {
  const user = ref<User | null>(null);
  const isAdmin = computed(() => user.value?.role === "admin");

  async function login(email: string, password: string) { /* ... */ }
  function logout() { user.value = null; }

  return { user, isAdmin, login, logout };
});
```

Rules:

- One store per domain (`useAuthStore`, `useTasksStore`). Don't make a
  monolithic `useAppStore` — it becomes the new Vuex root.
- TTL-cache reads inside the store (`if (Date.now() - lastFetch < 60_000) return;`).
  Components don't have to remember to debounce.
- Stores are global. Don't put per-route state there — use a composable
  scoped to the component that owns the route.
- Don't import a store inside another store's top-level — call it inside
  the action that needs it. Top-level imports create circular initialization.

## Reactivity Gotchas

These bite once and you remember forever. Better to read them now.

| Gotcha | Why | Fix |
| ------ | --- | --- |
| `const { foo } = reactive(state)` loses reactivity | `foo` is a plain value at the moment of destructure | Use `toRefs(state)` or keep `state.foo` access |
| Mutating an array prop directly | Props are readonly | Emit an event; let parent mutate |
| `ref` of a `Map` / `Set` doesn't trigger updates | Vue 3 has special handling for Map/Set; pre-3.0.11 it didn't | Replace the whole map: `m.value = new Map(m.value).set(k, v)` |
| `computed` with side effects | `computed` should be pure; effects belong in `watchEffect` | Move side effects to `watchEffect` or `watch` |
| `watch(source, cb)` not firing | Source isn't reactive (raw value) or watcher set up before mount | Pass a getter `() => source.value`, ensure watcher in `setup` |
| `v-for` over a `Map` doesn't update | Same as above; Vue tracks reference identity for collections | Use `Array.from(map.value)` or replace whole map |
| `unref(x)` on a non-ref returns `x` | Useful for "ref or value" inputs | `function setName(name: MaybeRef<string>) { const v = unref(name); ... }` |

## Performance: Where Vue Bleeds

Vue's reactivity is fast, but four patterns dominate slow apps:

1. **Big lists without virtualization** — 1000+ rows render every cell.
   Use `RecycleScroller` (`vue-virtual-scroller`) or `vue-virtual-list`
   for any list that can grow past 200 items.

2. **Computed dependency cycles** — a `computed` that reads another
   `computed` that reads the first. Vue detects and warns; fix the design.

3. **Deep watchers on big objects** — `watch(state, ..., { deep: true })`
   walks every property on every change. Replace with a watcher on a
   specific path, or restructure state into smaller refs.

4. **Component re-renders on parent state change** — Vue 3 is much better
   than Vue 2 here, but a `<HeavyComponent v-bind="$props" />` still
   re-renders on every parent change. Use `defineProps` precisely and
   memoize children with `v-memo` for expensive trees.

## `v-memo` For Expensive Trees

`v-memo` skips re-render when the dependency array hasn't changed.
Use sparingly — it's a manual optimization, easy to misuse:

```vue
<div v-for="item in items" :key="item.id" v-memo="[item.id, item.updatedAt]">
  <!-- expensive -->
</div>
```

Only the listed deps trigger re-render. Forget to include one and you
get stale UI.

## Volar / vue-tsc Strictness

Pin Volar to the workspace TypeScript SDK so editor diagnostics match CI.
In `tsconfig.json`:

```json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "moduleResolution": "Bundler",
    "skipLibCheck": true
  },
  "vueCompilerOptions": {
    "strictTemplates": true
  }
}
```

`strictTemplates` makes typos in template bindings (`{{ user.namee }}`) a
compile error. Worth the upfront pain.

CI runs `vue-tsc --noEmit` — same check the IDE shows. Drift between the
two means you're shipping bugs that look fine locally.

## Slots: Type Them

```vue
<script setup lang="ts">
defineSlots<{
  default(props: { task: Task }): unknown;
  empty(): unknown;
}>();
</script>

<template>
  <slot v-if="tasks.length" name="default" :task="tasks[0]" />
  <slot v-else name="empty" />
</template>
```

`defineSlots` gives consumers compile-time type-safety on slot props and
on which slots exist. Without it, slot misuse fails at runtime.

## Async Components For Routes

```ts
const RoadmapView = defineAsyncComponent(() => import("@/views/RoadmapView.vue"));
```

Or in the router:

```ts
{ path: "/roadmap", component: () => import("@/views/RoadmapView.vue") }
```

Keeps the initial bundle small. Combined with Vite's `manualChunks` for
heavy libs (mxGraph, Chart.js), the first paint stays fast even as the
app grows.

## Common Smells

| Smell | Fix |
| ----- | --- |
| `data() { return { ... } }` in a new file | Use `<script setup>` + `ref` |
| `this.$emit` (Options API) in 2024+ codebase | `defineEmits` typed events |
| `ref(somePropValue)` then never updating | Use a `computed`, or `watch` the prop |
| `reactive({ list: [], filters: {} })` then `const { list } = state` | Either `toRefs(state)` or use `ref` per field |
| Pinia `state: () => ({ ... })` + `actions: { ... }` (options form) | Setup form: closure over refs and functions |
| `v-for` without `:key` (or `:key="index"`) | Use a stable id: `:key="item.id"` |
| `:style="{...}"` rebuilt every render with the same object | `computed(() => ({ ... }))` |
| Watching deep state to derive a value | Replace with `computed` |

## Skill Chain

1. **frontend-patterns** — generic component / state / hook patterns
2. **vue3-patterns** — this skill (Vue-specific depth)
3. **frontend-design** — typography, color, motion, aesthetics
4. **typescript-patterns** — narrowing, branded types, exhaustiveness

## Purpose

Principal-level Vue 3 engineering: Composition API + `<script
setup>` as the canonical default, composables (the `useX()`
pattern) for reusable logic, Pinia for global state, reactivity
discipline (no destructuring `reactive()` — that loses
reactivity), `ref` vs `reactive` semantics, computed vs watchers,
provide / inject scoping, async setup + `<Suspense>`, Teleport
for portals, dynamic + async components for code-splitting,
defineModel for v-model, TypeScript + Volar for type safety, and
the SSR + hydration discipline (Nuxt 3 / Vite SSR).

**Negative scope** (NOT what this skill covers):

- Generic frontend patterns — see `frontend-patterns`
- Visual design quality (typography, colour, motion) — see
  `frontend-patterns` design section
- TypeScript depth — see `typescript-patterns`
- Vue 2 / Options API patterns — out (Vue 2 EOL 2024-Dec-31)
- React / Svelte / Angular — different frameworks

## When NOT to use

- Pure server-rendered HTML pages with no interactivity (use
  templates + a sprinkle of vanilla JS)
- Single-file widget embedded in another app's DOM (Vue's runtime
  cost is large relative to vanilla / Preact for tiny widgets)
- Existing Vue 2 codebase mid-migration (Composition API can be
  introduced gradually; full Vue 3 patterns require the runtime)

## Standards Cited

- **Vue 3 Documentation** (vuejs.org) — canonical reference
- **Vue Style Guide — Priority A/B/C/D** (vuejs.org/style-guide)
- **Pinia Documentation** (pinia.vuejs.org) — official state mgmt
- **Vue Router 4 Documentation** — routing
- **Nuxt 3 Documentation** (nuxt.com) — SSR + meta-framework
- **Volar / vue-tsc** — type checking
- **WHATWG HTML Living Standard** — base
- **WAI-ARIA 1.2** — accessibility primitives for custom components
- **WCAG 2.2** — accessibility floor (via `a11y.md`)
- **OWASP ASVS 4.0.3 §5.3 (Output Encoding + Injection Prevention)** —
  XSS guards (Vue's auto-escaping + sanitisation)
- **OWASP ASVS 4.0.3 §14.4 (HTTP Security Headers)** — CSP +
  framework integration

## Anti-Patterns

| Pattern | Why bad | Correct alternative |
| --- | --- | --- |
| Options API in new code | Inconsistent with team standard; harder to reuse logic | `<script setup>` + Composition API |
| Destructuring `reactive()` | Loses reactivity (silent bug) | Use `toRefs(state)` OR keep state as `reactive` and access via `state.x` |
| Mutating props directly | Vue dev mode warns; breaks one-way data flow | `emit('update:modelValue', x)` or `defineModel()` |
| `v-html` with user input | XSS vector | Render text content; or sanitise via DOMPurify before `v-html` |
| Inline event handler with complex logic | Untestable; coupling | Extract to method / composable |
| Watching reactive object without `deep: true` | Misses nested changes | Use `watch(source, fn, { deep: true })` OR `watchEffect` |
| `ref().value` reassign in template | Templates auto-unwrap; manual `.value` breaks reactivity | Use the ref directly in templates |
| Global state in module-level `ref()` | Singleton across tests / SSR (memory leak across requests) | Pinia store (per-app instance) |
| `setup()` with async work + no `<Suspense>` | Component hangs without fallback | Wrap in `<Suspense>` with `#fallback` |
| Component name collision with HTML | Some browsers ignore custom elements without `-` | Multi-word PascalCase / kebab-case |
| Provide / inject without `Symbol` keys | Name collision in larger apps | Typed `InjectionKey<T>` |
| List rendering with index as `:key` | Animation glitches on reorder | Stable id from data |
| Composable side effects in setup() not cleaned up | Leaks across HMR / hot reload | Use `onScopeDispose` / `onBeforeUnmount` |

## Verification Checklist

- [ ] Composition API + `<script setup>` used in all new files
- [ ] Volar + `vue-tsc --noEmit` returns zero errors
- [ ] No `reactive()` destructured without `toRefs()`
- [ ] No `v-html` with user-controlled input
- [ ] Composables extracted on second occurrence (rule of three)
- [ ] Global state via Pinia (per-app instance), never module-level
      `ref()`
- [ ] Lists keyed by stable id from data (S6479)
- [ ] Async setup wrapped in `<Suspense>` with fallback
- [ ] Provide / inject use typed `InjectionKey<T>` symbols
- [ ] Watchers use appropriate `deep` / `immediate` flags
- [ ] `defineModel()` (3.4+) for two-way binding
- [ ] Accessibility audited per `a11y.md` (ARIA, keyboard nav,
      focus management)
- [ ] No raw colour literals; design tokens via CSS vars
- [ ] SSR-safe (no `window` access outside `onMounted`)
- [ ] Bundle size budgeted (Vite chunk analysis)

## Cross-References

- `~/.claude/skills/frontend-patterns/SKILL.md` — generic patterns
- `~/.claude/skills/typescript-patterns/SKILL.md` — TS depth
- `~/.claude/skills/coding-quality-rules/SKILL.md` — universal baseline
- `~/.claude/rules-library/common/a11y.md` — accessibility floor
- `~/.claude/rules-library/common/no-discards.md` — Vue-specific S6299
  (no-bypass-sanitization) ban
- `~/.claude/rules-library/common/extreme-lint-policy.md` — eslint-plugin-vue
  - sonarjs strict
- `~/.claude/agents/code-reviewer.md` — Council Division 3
- `~/.claude/agents/ux-reviewer.md` — Council Division 7
- Vue 3 Style Guide (vuejs.org/style-guide)

## Why this skill exists

Vue 3's Composition API + `<script setup>` are the canonical
baseline for new Vue work — but the framework's flexibility means
teams ship Vue 2 idioms, reactive() destructuring bugs, `v-html`
XSS, and Pinia-vs-module-level-state confusion. The patterns above
codify the production-ready posture: Composition API, typed
composables, reactivity discipline, Pinia for global state,
SSR-safety, ARIA-correct components, design-token-based styling.
Teams that adopt these maintain Vue's velocity advantage; teams
that don't accumulate the silent-bug debt the reactivity system
hides behind interactive UIs.

## Learning hooks

Per `~/.claude/rules/common/continuous-learning-mandate.md`:

**Signals to watch**:

- Options API used in new code where Composition API + `<script setup>` is the canonical baseline
- `ref` / `reactive` misuse (e.g., destructuring reactive object loses reactivity — silent bug)
- `watchEffect` used where `watch` with explicit deps would be clearer
- Composable function not prefixed with `use` (Vue convention drift)
- Pinia store accessed inside `setup` without `storeToRefs` for destructuring (reactivity loss)
- Template directives misused (`v-if` + `v-for` on same element, deprecated `v-html` without
  sanitization)
- `provide` / `inject` used as a global state shortcut instead of Pinia
- Component prop typed as `any` / `Object` instead of typed interface
- `defineExpose` used to leak internals instead of defining a proper public API
- Reactivity gotcha: mutating a `readonly` prop / mutating arrays / Maps without `.value`

**Refinement candidates**:

- New idiom row when Vue ships a new release (e.g., Vapor mode, new macros)
- Tightening of the `<script setup>` enforcement when Options API recurs in new code
- New cross-reference when a sister skill (frontend-patterns, typescript-patterns, accessible-forms)
  adds a Vue-relevant gate
- New composable template when a recurring shared pattern emerges (e.g., `useDebounce`,
  `usePagination`)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…