vue
Vue 3 conventions, Composition API patterns, SFC structure, reactivity, composables, and TypeScript integration. Invoke whenever task involves any interaction with Vue code — writing, reviewing, refac
SKILL.mdplugins/frontend/skills/vue/SKILL.md
GitHubVue
Composition API is the default. <script setup> is the default syntax. If you reach for Options API, you need a
reason.
Vue 3 rewards explicit, composable code. Prefer ref() over reactive(), composables over mixins, and typed props over
runtime-only validation. References contain extended examples, rationale, and edge cases for each topic.
References
- Reactivity — [
${CLAUDE_SKILL_DIR}/references/reactivity.md]: Ref unwrapping, watchers, computed edge cases - SFC — [
${CLAUDE_SKILL_DIR}/references/sfc.md]: Full compiler macros catalog, scoped styles, template refs - Components — [
${CLAUDE_SKILL_DIR}/references/components.md]: Props, emits, slots, provide/inject - Composables — [
${CLAUDE_SKILL_DIR}/references/composables.md]: Design patterns, composition, restrictions - TypeScript — [
${CLAUDE_SKILL_DIR}/references/typescript.md]: Full utility types table, generic components, event typing - Performance — [
${CLAUDE_SKILL_DIR}/references/performance.md]: Update optimization, large lists, profiling
Reactivity
Choosing a Reactive Primitive
ref()— Default choice. Works with any value type.reactive()— Grouping related state when destructure is not needed.shallowRef()— Large immutable structures, external state integration.shallowReactive()— Root-level-only reactivity on objects.computed()— Derived state. Caches until dependencies change.
ref() Is the Primary API
- Works with primitives (
string,number,boolean). - Can be destructured from composable returns without losing reactivity.
- Can be reassigned (
count.value = newValue). - Consistent
.valueaccess pattern everywhere in script. - Access
.valuein script, omit in template — templates auto-unwrap top-level refs.
reactive() Limitations
- Cannot hold primitives.
- Reassignment loses reactivity —
state = reactive({...})breaks tracking. - Destructuring primitives loses reactivity — use
toRefs()if you must destructure. - Do not use
reactive()as the primary primitive. Useref().
Ref Unwrapping Rules
- Top-level refs in templates are auto-unwrapped:
{{ count }}works. - Non-top-level refs in plain objects are NOT unwrapped:
{{ obj.id + 1 }}breaks ifobj.idis a ref. Destructure to top level to fix. - Refs nested inside
reactive()objects are unwrapped automatically. - Refs inside reactive arrays/collections are NOT unwrapped — need
.value.
Computed Properties
- Keep computed getters pure — no side effects.
- Split complex computed into smaller ones.
- Computed caches its value; only recalculates when dependencies change.
- Computed stability (3.4+): only triggers effects when the returned value actually changes. Avoid returning new objects from computed — each new object is "different".
- Writable computed is rare — use sparingly. Requires
get/setform.
Watchers
watch() vs watchEffect():
Use watch() when |
Use watchEffect() when |
|---|---|
| Need old and new values | Don't need old value |
| Want lazy execution | Want immediate execution |
| Watching specific sources | Dependencies are implicit |
| Need conditional watching | Effect tracks all accessed refs |
watch() options:
{ immediate: true }— run callback on creation (like watchEffect).{ deep: true }— watch all nested properties (expensive, use sparingly).- Watch a getter for specific property:
watch(() => obj.specificProp, callback). - Watch multiple sources:
watch([a, b], ([newA, newB]) => {...}).
Cleanup: Both watch and watchEffect support cleanup via the onCleanup parameter. Use it for aborting fetch
requests, clearing timers, removing listeners. onWatcherCleanup() (3.5+) registers a cleanup only before the first
await of an async callback: after it, Vue warns and drops the cleanup. The onCleanup parameter has no such limit.
Async watchEffect: dependency tracking stops at the first await. Read every reactive source before it, or use
watch() with explicit sources.
Route params: Vue Router reuses the component instance when only the params change, as from /users/1 to
/users/2: setup and the mount hooks do not run again. Load route-dependent data with
watch(() => route.params.id, load, { immediate: true }), never only in setup or onMounted.
DOM Update Timing
Reactive state changes batch DOM updates to the next tick. Use nextTick() for post-DOM-update logic.
Single-File Components
Block Order
Always: <script setup> first, <template> second, <style> last.
Organization Within <script setup>
Order declarations logically:
- Imports — Vue APIs, components, composables, types
- Props and emits —
defineProps,defineEmits - Composable calls —
useRouter(), custom composables - Reactive state —
ref(),reactive(),computed() - Functions — event handlers, helpers
- Watchers —
watch(),watchEffect() - Lifecycle hooks —
onMounted(),onUnmounted() - Expose —
defineExpose()(rare)
Compiler Macros
Key macros available without import in <script setup>: defineProps(), defineEmits(), defineModel(). Use
defineOptions() for options that <script setup> doesn't natively support (name, inheritAttrs: false). Full macro
catalog in ${CLAUDE_SKILL_DIR}/references/sfc.md.
Template Syntax
Directive shorthands — use consistently, don't mix:
:prop(v-bind:prop) — Bind attribute/prop@event(v-on:event) — Listen to event#slot(v-slot:slot) — Named slot
Conditional rendering:
v-if/v-else-if/v-elsefor conditional blocks.v-showfor frequent toggles (CSSdisplay: none, avoids mount/unmount cost).- Use
v-iffor conditions that rarely change,v-showfor frequent toggles.
Template refs:
- 3.5+:
useTemplateRef<HTMLInputElement>('input')with matchingrefattribute. - Pre-3.5:
ref<HTMLInputElement | null>(null)with matching ref name.
Scoped Styles
- Use
<style scoped>by default. Global styles only inApp.vueor layouts. - Child component root elements are affected by both parent and child scoped styles.
- Deep selectors:
.parent :deep(.child-class)to style child internals (use sparingly). - CSS modules:
<style module>generates unique class names, accessed via$style. v-bind(color)in<style>uses reactive values in CSS.
Components
Naming
- Multi-word names always —
TodoItemnotItem. Avoids HTML element conflicts. - PascalCase in SFC templates:
<TodoItem />. Kebab-case only in in-DOM templates. - PascalCase filenames:
TodoItem.vue. - Base component prefix for presentational components:
BaseButton,BaseIcon. - Parent prefix for tightly coupled children:
TodoListItem,TodoListItemButton. - Highest-level word first to group related:
SearchButtonClear,SearchInputQuery. - Full words always — no abbreviations.
- One component per file. No inline registration.
- Self-closing tags for components without children:
<MyComponent />. - PascalCase when importing in JS/TS.
Props
- Use
defineProps<T>()(type-based) in TypeScript projects. Object syntax in JS projects. Never array syntax in committed code. - Declare in
camelCase, use in templates askebab-case— Vue converts automatically. - Never mutate props. Use
computed()for transformations,ref()+ initial value for local copies. - One-way data flow — props down, events up.
- Reactive props destructure (3.5+): destructured props are reactive inside
computedandwatchEffect.watchneeds a getter,watch(() => count, …); passingcountdirectly is a compile error. - Pass destructured props to composables via getter:
useComposable(() => id).
Emits
- Declare all emits with
defineEmits()— preferably type-based. - Emit in
camelCase:emit('someEvent'). Listen inkebab-case:@some-event="handler". Vue converts automatically. - Use named tuple syntax (3.3+) for type-based emits:
defineEmits<{ change: [id: number] }>().
v-model
- Use
defineModel<T>()for two-way binding shorthand. - Named v-model:
defineModel<string>('firstName')withv-model:first-name="first"on parent.
Slots
- Default slot:
<slot />in child, content between tags in parent. - Named slots:
<slot name="header" />in child,<template #header>in parent. - Scoped slots: pass data via slot props —
<slot :item="item" />, consume with<template #default="{ item }">.
Provide / Inject
- Use
Symbolkeys (InjectionKey<T>) for type safety — avoid string collisions. - Export keys from a shared
keys.tsfile. - Provide
readonly()refs to prevent consumers from mutating state. - Provide updater functions when consumers need to change provided state.
- Always provide a default or handle
undefinedin the consumer.
Template Rules
v-foralways has:key— stable, unique identifiers.- Never
v-ifon same element asv-for. Usecomputedto filter, or wrap with<template v-for>. - Simple expressions only in templates. Move logic to
computedor functions. - Multi-attribute elements span multiple lines — one attribute per line.
SSR Hydration
The first client render must produce the server's HTML. A mismatch comes from:
- invalid HTML nesting, such as
<div>inside<p>: the browser's parser rewrites the server HTML before Vue hydrates - a value that differs between the two runs — a random value, the current time, a local-time conversion done in the server's time zone
Render such a value only on the client, behind a v-if flag that onMounted sets. Reserve data-allow-mismatch (3.5+)
for one element whose text must differ; it silences the warning, not the cause.
Composables
Design
- Always prefix with
use:useMouse,useFetch,useAuth. camelCasenaming:useEventListenernotuse-event-listener.- The
useprefix signals the function uses Vue reactivity and must be called withinsetup()or<script setup>.
Structure
Follow this order: (1) reactive state, (2) logic that modifies state, (3) lifecycle hooks for side effects, (4) return refs.
Return Values
Always return a plain object containing refs. Never return a reactive() object — it loses reactivity on destructure.
If the consumer wants an object, they can wrap: const mouse = reactive(useMouse()).
Input Arguments
Accept refs, getters, and raw values. Use toValue() (with MaybeRefOrGetter<T> type) to normalize inputs inside
watchEffect so reactive sources are tracked.
Side Effects and Cleanup
- Always clean up side effects in
onUnmounted()— event listeners, timers, subscriptions. - SSR safety: DOM-specific effects go in
onMounted()/onUnmounted(), not at top level.
Composition
Composables can call other composables — build complex logic by composing simple hooks.
Usage Restrictions
- Must be called inside
<script setup>orsetup()function. - Must be called synchronously — not inside async callbacks or promises.
- Exception:
<script setup>restores the active instance afterawait, so composables work afterawaitin<script setup>. - Inside lifecycle hooks like
onMounted()is acceptable.
Composables vs Alternatives
- Mixins — Unclear property sources, namespace collisions, implicit coupling
- Renderless components — Extra component instance overhead
- Utility functions — No reactive state or lifecycle hooks
TypeScript
Props
- Use
defineProps<T>()with an interface. - Defaults with 3.5+ destructure:
const { title, count = 0 } = defineProps<Props>(). - Defaults with 3.4 and below:
withDefaults(defineProps<Props>(), { count: 0 }). Mutable reference defaults (arrays, objects) must use factory functions withwithDefaults. - Imported types work since Vue 3.3.
Emits
Use named tuple syntax (3.3+): defineEmits<{ change: [id: number] }>().
Refs
- Refs infer types from initial values —
ref(0)isRef<number>. - Use explicit type for union types:
ref<string | number>('2024'). - Nullable:
ref<User | null>(null). - Without initial value:
ref<ResponseData>()yieldsRef<ResponseData | undefined>.
Computed
Types are inferred. Use explicit generic only when inference falls short: computed<string>(() => ...).
Reactive
Annotate with interface, do not use reactive<T>() generic — the returned type handles ref unwrapping differently from
the generic parameter.
Template Refs
- 3.5+:
useTemplateRef<HTMLInputElement>('input'). - Component refs:
useTemplateRef<InstanceType<typeof MyComponent>>('comp'). - Pre-3.5:
ref<HTMLInputElement | null>(null).
Provide / Inject
Use InjectionKey<T> for type-safe keys. String keys require generic annotation: inject<string>('key').
Generic Components
Use generic attribute on <script setup>: <script setup lang="ts" generic="T extends string | number">.
Event Handlers
Type DOM events explicitly: (event: Event) then cast target: event.target as HTMLInputElement.
Utility Types
Use Vue's typed helpers: Ref<T>, ComputedRef<T>, MaybeRefOrGetter<T>, InjectionKey<T>, PropType<T>,
ComponentPublicInstance. Full utility types table in ${CLAUDE_SKILL_DIR}/references/typescript.md.
Performance
Page Load
- Use dynamic imports for route-level code splitting.
- Use
defineAsyncComponent()for component-level splitting. - Prefer tree-shakable dependencies (
lodash-esoverlodash). <script setup>compiles to more minification-friendly code than Options API.
Update Performance
- Props stability — pass derived booleans (
active="item.id === activeId") instead of raw IDs to prevent unnecessary child re-renders. v-once— render once, skip all future updates. For truly static content.v-memo— conditionally skip sub-tree updates. Accepts a dependency array; only re-renders when a dependency changes. Use onv-forlists where most items don't change.- Computed stability (3.4+) — computed only triggers effects when value actually changes. Avoid returning new objects from computed (each new object is "different").
Large Lists
- Virtual scrolling for 1000+ items — render only visible items (
vue-virtual-scroller,vueuc/VVirtualList). shallowRef()for large immutable datasets — mutations must replace the whole value.- Avoid unnecessary wrapper components in large lists — every component instance has overhead.
Watch Performance
- Avoid
{ deep: true }on large objects — watch specific properties instead. - Throttle/debounce watchers for expensive callbacks (e.g.,
useDebounceFnfrom VueUse).
Profiling
- Enable
app.config.performance = truefor Vue-specific performance markers. - Use Chrome DevTools Performance panel and Vue DevTools profiler.
- Measure before and after — don't optimize without data.
Application
When writing Vue code:
- Apply all conventions silently — don't narrate each rule being followed.
- If an existing codebase contradicts a convention, follow the codebase and flag the divergence once.
- Always use
<script setup>unless there is a documented reason not to.
When reviewing Vue code:
- Cite the specific violation and show the fix inline.
- Don't lecture or quote the rule — state what's wrong and how to fix it.
Integration
This skill provides Vue-specific conventions. The coding skill governs workflow; language skills govern JS/TS choices; this skill governs component architecture, reactivity patterns, and framework API usage.