
Vue Best Practices
- 25.3k installs
- 2.8k repo stars
- Updated May 30, 2026
- hyf0/vue-skills
vue-best-practices is a skill that guides Vue 3 development with best practices covering Composition API, TypeScript, and performance.
About
Vue 3 development best practices including Composition API, TypeScript, SSR guidance, and performance optimization. Covers gotchas, common mistakes, and validated patterns through automated evaluation. Early experiment in specialized Vue 3 agent skills.
- Vue 3 + Composition API
- TypeScript guidance
- Performance optimization
Vue Best Practices by the numbers
- 25,290 all-time installs (skills.sh)
- +201 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #25 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
vue-best-practices capabilities & compatibility
- Use cases
- frontend
npx skills add https://github.com/hyf0/vue-skills --skill vue-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25.3k |
|---|---|
| repo stars | ★ 2.8k |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 30, 2026 |
| Repository | hyf0/vue-skills ↗ |
How do you enforce Vue 3 Composition API best practices?
Guide agents building Vue 3 applications with best practices validated through automated evaluation.
Who is it for?
Developers building Vue 3 frontends with Vite, Vue Router, and Pinia who want agents to follow official Composition API and TypeScript conventions.
Skip if: Developers maintaining legacy Vue 2 Options API codebases that explicitly forbid Composition API migration.
When should I use this skill?
Any task touches Vue, .vue files, Vue Router, Pinia, Vite with Vue, SSR, Volar, or vue-tsc and needs official Vue 3 standards applied.
What you get
Vue 3 components using script setup, typed props and emits, predictable reactivity patterns, and Volar-compatible TypeScript configuration.
- standards-compliant .vue components
- typed Composition API patterns
By the numbers
- vue-best-practices version 18.0.0
- MIT licensed, authored by github.com/vuejs-ai
Files
Vue Best Practices Workflow
Use this skill as an instruction set. Follow the workflow in order unless the user explicitly asks for a different order.
Core Principles
- Keep state predictable: one source of truth, derive everything else.
- Make data flow explicit: Props down, Events up for most cases.
- Favor small, focused components: easier to test, reuse, and maintain.
- Avoid unnecessary re-renders: use computed properties and watchers wisely.
- Readability counts: write clear, self-documenting code.
1) Confirm architecture before coding (required)
- Default stack: Vue 3 + Composition API +
<script setup lang="ts">. - If the project explicitly uses Options API, load
vue-options-api-best-practicesskill if available. - If the project explicitly uses JSX, load
vue-jsx-best-practicesskill if available.
1.1 Must-read core references (required)
- Before implementing any Vue task, make sure to read and apply these core references:
references/reactivity.mdreferences/sfc.mdreferences/component-data-flow.mdreferences/composables.md- Keep these references in active working context for the entire task, not only when a specific issue appears.
1.2 Plan component boundaries before coding (required)
Create a brief component map before implementation for any non-trivial feature.
- Define each component's single responsibility in one sentence.
- Keep entry/root and route-level view components as composition surfaces by default.
- Move feature UI and feature logic out of entry/root/view components unless the task is intentionally a tiny single-file demo.
- Define props/emits contracts for each child component in the map.
- Prefer a feature folder layout (
components/<feature>/...,composables/use<Feature>.ts) when adding more than one component.
2) Apply essential Vue foundations (required)
These are essential, must-know foundations. Apply all of them in every Vue task using the core references already loaded in section 1.1.
Reactivity
- Must-read reference from
1.1: reactivity - Keep source state minimal (
ref/reactive), derive everything possible withcomputed. - Use watchers for side effects if needed.
- Avoid recomputing expensive logic in templates.
SFC structure and template safety
- Must-read reference from
1.1: sfc - Keep SFC sections in this order:
<script>→<template>→<style>. - Keep SFC responsibilities focused; split large components.
- Keep templates declarative; move branching/derivation to script.
- Apply Vue template safety rules (
v-html, list rendering, conditional rendering choices).
Keep components focused
Split a component when it has more than one clear responsibility (e.g. data orchestration + UI, or multiple independent UI sections).
- Prefer smaller components + composables over one “mega component”
- Move UI sections into child components (props in, events out).
- Move state/side effects into composables (
useXxx()).
Apply objective split triggers. Split the component if any condition is true:
- It owns both orchestration/state and substantial presentational markup for multiple sections.
- It has 3+ distinct UI sections (for example: form, filters, list, footer/status).
- A template block is repeated or could become reusable (item rows, cards, list entries).
Entry/root and route view rule:
- Keep entry/root and route view components thin: app shell/layout, provider wiring, and feature composition.
- Do not place full feature implementations in entry/root/view components when those features contain independent parts.
- For CRUD/list features (todo, table, catalog, inbox), split at least into:
- feature container component
- input/form component
- list (and/or item) component
- footer/actions or filter/status component
- Allow a single-file implementation only for very small throwaway demos; if chosen, explicitly justify why splitting is unnecessary.
Component data flow
- Must-read reference from
1.1: component-data-flow - Use props down, events up as the primary model.
- Use
v-modelonly for true two-way component contracts. - Use provide/inject only for deep-tree dependencies or shared context.
- Keep contracts explicit and typed with
defineProps,defineEmits, andInjectionKeyas needed.
Composables
- Must-read reference from
1.1: composables - Extract logic into composables when it is reused, stateful, or side-effect heavy.
- Keep composable APIs small, typed, and predictable.
- Separate feature logic from presentational components.
3) Consider optional features only when requirements call for them
3.1 Standard optional features
Do not add these by default. Load the matching reference only when the requirement exists.
- Slots: parent needs to control child content/layout -> component-slots
- Fallthrough attributes: wrapper/base components must forward attrs/events safely -> component-fallthrough-attrs
- Built-in component
<KeepAlive>for stateful view caching -> component-keep-alive - Built-in component
<Teleport>for overlays/portals -> component-teleport - Built-in component
<Suspense>for async subtree fallback boundaries -> component-suspense - Animation-related features: pick the simplest approach that matches the required motion behavior.
- Built-in component
<Transition>for enter/leave effects -> transition - Built-in component
<TransitionGroup>for animated list mutations -> transition-group - Class-based animation for non-enter/leave effects -> animation-class-based-technique
- State-driven animation for user-input-driven animation -> animation-state-driven-technique
3.2 Less-common optional features
Use these only when there is explicit product or technical need.
- Directives: behavior is DOM-specific and not a good composable/component fit -> directives
- Async components: heavy/rarely-used UI should be lazy loaded -> component-async
- Render functions only when templates cannot express the requirement -> render-functions
- Plugins when behavior must be installed app-wide -> plugins
- State management patterns: app-wide shared state crosses feature boundaries -> state-management
4) Run performance optimization after behavior is correct
Performance work is a post-functionality pass. Do not optimize before core behavior is implemented and verified.
- Large list rendering bottlenecks -> perf-virtualize-large-lists
- Static subtrees re-rendering unnecessarily -> perf-v-once-v-memo-directives
- Over-abstraction in hot list paths -> perf-avoid-component-abstraction-in-lists
- Expensive updates triggered too often -> updated-hook-performance
5) Final self-check before finishing
- Core behavior works and matches requirements.
- All must-read references were read and applied.
- Reactivity model is minimal and predictable.
- SFC structure and template rules are followed.
- Components are focused and well-factored, splitting when needed.
- Entry/root and route view components remain composition surfaces unless there is an explicit small-demo exception.
- Component split decisions are explicit and defensible (responsibility boundaries are clear).
- Data flow contracts are explicit and typed.
- Composables are used where reuse/complexity justifies them.
- Moved state/side effects into composables if applicable
- Optional features are used only when requirements demand them.
- Performance changes were applied only after functionality was complete.
Use Class-based Animations for Non-Enter/Leave Effects
Impact: LOW - For animations on elements that are not entering or leaving the DOM, use CSS class-based animations triggered by Vue's reactive state. This is simpler than <Transition> and more appropriate for feedback animations like shake, pulse, or highlight effects.
Task List
- Use class-based animations for elements staying in the DOM
- Use
<Transition>only for enter/leave animations - Combine CSS animations with Vue's class bindings (
:class) - Consider using
setTimeoutto auto-remove animation classes
When to Use Class-based Animations:
- User feedback (shake on error, pulse on success)
- Attention-grabbing effects (highlight changes)
- Hover/focus states that need more than CSS transitions
- Any animation where the element stays mounted
When to Use Transition Component:
- Elements entering/leaving the DOM (v-if/v-show)
- Route transitions
- List item additions/removals
Basic Pattern
<template>
<div :class="{ shake: showError }">
<button @click="submitForm">Submit</button>
<span v-if="showError">This feature is disabled!</span>
</div>
</template>
<script setup>
import { ref } from 'vue'
const showError = ref(false)
function submitForm() {
if (!isValid()) {
// Trigger shake animation
showError.value = true
// Auto-remove class after animation completes
setTimeout(() => {
showError.value = false
}, 820) // Match animation duration
}
}
</script>
<style>
.shake {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0); /* Enable GPU acceleration */
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
</style>Common Animation Patterns
Pulse on Success
<template>
<button
@click="save"
:class="{ pulse: saved }"
>
{{ saved ? 'Saved!' : 'Save' }}
</button>
</template>
<script setup>
import { ref } from 'vue'
const saved = ref(false)
async function save() {
await saveData()
saved.value = true
setTimeout(() => saved.value = false, 1000)
}
</script>
<style>
.pulse {
animation: pulse 0.5s ease-in-out;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
</style>Highlight on Change
<template>
<div
:class="{ highlight: justUpdated }"
>
Value: {{ value }}
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const value = ref(0)
const justUpdated = ref(false)
watch(value, () => {
justUpdated.value = true
setTimeout(() => justUpdated.value = false, 1000)
})
</script>
<style>
.highlight {
animation: highlight 1s ease-out;
}
@keyframes highlight {
0% { background-color: yellow; }
100% { background-color: transparent; }
}
</style>Bounce Attention
<template>
<div
:class="{ bounce: needsAttention }"
@animationend="needsAttention = false"
>
<BellIcon />
</div>
</template>
<script setup>
import { ref } from 'vue'
const needsAttention = ref(false)
function notifyUser() {
needsAttention.value = true
// No setTimeout needed - using animationend event
}
</script>
<style>
.bounce {
animation: bounce 0.5s ease;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
</style>Using animationend Event
Instead of setTimeout, use the animationend event for cleaner code:
<template>
<div
:class="{ animate: isAnimating }"
@animationend="isAnimating = false"
>
Content
</div>
</template>
<script setup>
import { ref } from 'vue'
const isAnimating = ref(false)
function triggerAnimation() {
isAnimating.value = true
// Class is automatically removed when animation ends
}
</script>Composable for Reusable Animations
// composables/useAnimation.js
import { ref } from 'vue'
export function useAnimation(duration = 500) {
const isAnimating = ref(false)
function trigger() {
isAnimating.value = true
setTimeout(() => {
isAnimating.value = false
}, duration)
}
return {
isAnimating,
trigger
}
}<script setup>
import { useAnimation } from '@/composables/useAnimation'
const shake = useAnimation(820)
const pulse = useAnimation(500)
</script>
<template>
<button
:class="{ shake: shake.isAnimating.value }"
@click="shake.trigger()"
>
Shake me
</button>
<button
:class="{ pulse: pulse.isAnimating.value }"
@click="pulse.trigger()"
>
Pulse me
</button>
</template>State-driven Animations with CSS Transitions and Style Bindings
Impact: LOW - For responsive, interactive animations that react to user input or state changes, combine Vue's dynamic style bindings with CSS transitions. This creates smooth animations that interpolate values in real-time based on state.
Task List
- Use
:stylebinding for dynamic properties that change frequently - Add CSS
transitionproperty to smoothly animate between values - Consider using
transformandopacityfor GPU-accelerated animations - For complex value interpolation, use watchers with animation libraries
Basic Pattern
<template>
<div
@mousemove="onMousemove"
:style="{ backgroundColor: `hsl(${hue}, 80%, 50%)` }"
class="interactive-area"
>
<p>Move your mouse across this div...</p>
<p>Hue: {{ hue }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const hue = ref(0)
function onMousemove(e) {
// Map mouse X position to hue (0-360)
const rect = e.currentTarget.getBoundingClientRect()
hue.value = Math.round((e.clientX - rect.left) / rect.width * 360)
}
</script>
<style>
.interactive-area {
transition: background-color 0.3s ease;
height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>Common Use Cases
Following Mouse Position
<template>
<div
class="container"
@mousemove="onMousemove"
>
<div
class="follower"
:style="{
transform: `translate(${x}px, ${y}px)`
}"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
const x = ref(0)
const y = ref(0)
function onMousemove(e) {
const rect = e.currentTarget.getBoundingClientRect()
x.value = e.clientX - rect.left
y.value = e.clientY - rect.top
}
</script>
<style>
.container {
position: relative;
height: 300px;
}
.follower {
position: absolute;
width: 20px;
height: 20px;
background: blue;
border-radius: 50%;
/* Smooth following with transition */
transition: transform 0.1s ease-out;
/* Prevent the follower from triggering mousemove */
pointer-events: none;
}
</style>Progress Animation
<template>
<div class="progress-container">
<div
class="progress-bar"
:style="{ width: `${progress}%` }"
/>
</div>
<input
type="range"
v-model.number="progress"
min="0"
max="100"
/>
</template>
<script setup>
import { ref } from 'vue'
const progress = ref(0)
</script>
<style>
.progress-container {
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #8BC34A);
transition: width 0.3s ease;
}
</style>Scroll-based Animation
<template>
<div
class="hero"
:style="{
opacity: heroOpacity,
transform: `translateY(${scrollOffset}px)`
}"
>
<h1>Scroll Down</h1>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const scrollY = ref(0)
const heroOpacity = computed(() => {
return Math.max(0, 1 - scrollY.value / 300)
})
const scrollOffset = computed(() => {
return scrollY.value * 0.5 // Parallax effect
})
function handleScroll() {
scrollY.value = window.scrollY
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<style>
.hero {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
/* Note: No transition for scroll-based animations - they should be instant */
}
</style>Color Theme Transition
<template>
<div
class="app"
:style="themeStyles"
>
<button @click="toggleTheme">Toggle Theme</button>
<p>Current theme: {{ isDark ? 'Dark' : 'Light' }}</p>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const isDark = ref(false)
const themeStyles = computed(() => ({
'--bg-color': isDark.value ? '#1a1a1a' : '#ffffff',
'--text-color': isDark.value ? '#ffffff' : '#1a1a1a',
backgroundColor: 'var(--bg-color)',
color: 'var(--text-color)'
}))
function toggleTheme() {
isDark.value = !isDark.value
}
</script>
<style>
.app {
min-height: 100vh;
transition: background-color 0.5s ease, color 0.5s ease;
}
</style>Advanced: Numerical Tweening with Watchers
For smooth number animations (counters, stats), use watchers with animation libraries:
<template>
<div>
<input v-model.number="targetNumber" type="number" />
<p class="counter">{{ displayNumber.toFixed(0) }}</p>
</div>
</template>
<script setup>
import { computed, ref, reactive, watch } from 'vue'
import gsap from 'gsap'
const targetNumber = ref(0)
const tweened = reactive({ value: 0 })
// Computed for display
const displayNumber = computed(() => tweened.value)
watch(targetNumber, (newValue) => {
gsap.to(tweened, {
duration: 0.5,
value: Number(newValue) || 0,
ease: 'power2.out'
})
})
</script>Performance Considerations
<style>
/* GOOD: GPU-accelerated properties */
.element {
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* AVOID: Properties that trigger layout recalculation */
.element {
transition: width 0.3s ease, height 0.3s ease, margin 0.3s ease;
}
/* For high-frequency updates, consider will-change */
.frequently-animated {
will-change: transform;
}
</style>Async Component Best Practices
Impact: MEDIUM - Async components should reduce JavaScript cost without degrading perceived performance. Focus on hydration timing in SSR and stable loading UX.
Task List
- Use lazy hydration strategies for non-critical SSR component trees
- Import only the hydration helpers you actually use
- Keep
loadingComponentdelay near the default200msunless real UX data suggests otherwise - Configure
delayandtimeouttogether for predictable loading behavior
Use Lazy Hydration Strategies in SSR
In Vue 3.5+, async components can delay hydration until idle time, visibility, media query match, or user interaction.
BAD:
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const AsyncComments = defineAsyncComponent({
loader: () => import('./Comments.vue')
})
</script>GOOD:
<script setup lang="ts">
import {
defineAsyncComponent,
hydrateOnVisible,
hydrateOnIdle
} from 'vue'
const AsyncComments = defineAsyncComponent({
loader: () => import('./Comments.vue'),
hydrate: hydrateOnVisible({ rootMargin: '100px' })
})
const AsyncFooter = defineAsyncComponent({
loader: () => import('./Footer.vue'),
hydrate: hydrateOnIdle(5000)
})
</script>Prevent Loading Spinner Flicker
Avoid showing loading UI immediately for components that usually resolve quickly.
BAD:
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
delay: 0
})
</script>GOOD:
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorDisplay from './ErrorDisplay.vue'
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 30000
})
</script>Delay Guidelines
| Scenario | Recommended Delay |
|---|---|
| Small component, fast network | 200ms |
| Known heavy component | 100ms |
| Background or non-critical UI | 300-500ms |
Component Data Flow Best Practices
Impact: HIGH - Vue components stay reliable when data flow is explicit: props go down, events go up, v-model handles two-way bindings, and provide/inject supports cross-tree dependencies. Blurring these boundaries leads to stale state, hidden coupling, and hard-to-debug UI.
The main principle of data flow in Vue.js is Props Down / Events Up. This is the most maintainable default, and one-way flow scales well.
Task List
- Treat props as read-only inputs
- Use props/emit for component communication; reserve refs for imperative actions
- When refs are required for imperative APIs, type them with template refs
- Emit events instead of mutating parent state directly
- Use
defineModelfor v-model in modern Vue (3.4+) - Handle v-model modifiers deliberately in child components
- Use symbols for provide/inject keys to avoid props drilling (over ~3 layers)
- Keep mutations in the provider or expose explicit actions
- In TypeScript projects, prefer type-based
defineProps,defineEmits, andInjectionKey
Props: One-Way Data Down
Props are inputs. Do not mutate them in the child.
BAD:
<script setup>
const props = defineProps({ count: Number })
function increment() {
props.count++
}
</script>GOOD:
If state needs to change, emit an event, use v-model or create a local copy.
Prefer props/emit over component refs
BAD:
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
const formRef = ref(null)
function submitForm() {
if (formRef.value.isValid) {
formRef.value.submit()
}
}
</script>
<template>
<UserForm ref="formRef" />
<button @click="submitForm">Submit</button>
</template>GOOD:
<script setup>
import UserForm from './UserForm.vue'
function handleSubmit(formData) {
api.submit(formData)
}
</script>
<template>
<UserForm @submit="handleSubmit" />
</template>Type component refs when imperative access is required
Prefer props/emits by default. When a parent must call an exposed child method, type the ref explicitly and expose only the intended API from the child with defineExpose.
BAD:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import DialogPanel from './DialogPanel.vue'
const panelRef = ref(null)
onMounted(() => {
panelRef.value.open()
})
</script>
<template>
<DialogPanel ref="panelRef" />
</template>GOOD:
<!-- DialogPanel.vue -->
<script setup lang="ts">
function open() {}
defineExpose({ open })
</script><!-- Parent.vue -->
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
import DialogPanel from './DialogPanel.vue'
// Vue 3.5+ with useTemplateRef
const panelRef = useTemplateRef('panelRef')
// Before Vue 3.5 with manual typing and ref
// const panelRef = ref<InstanceType<typeof DialogPanel> | null>(null)
onMounted(() => {
panelRef.value?.open()
})
</script>
<template>
<DialogPanel ref="panelRef" />
</template>Emits: Explicit Events Up
Component events do not bubble. If a parent needs to know about an event, re-emit it explicitly.
BAD:
<!-- Parent expects "saved" from grandchild, but it won't bubble -->
<Child @saved="onSaved" />GOOD:
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['saved'])
function onGrandchildSaved(payload) {
emit('saved', payload)
}
</script>
<template>
<Grandchild @saved="onGrandchildSaved" />
</template>Event naming: use kebab-case in templates and camelCase in script:
<script setup>
const emit = defineEmits(['updateUser'])
</script>
<template>
<ProfileForm @update-user="emit('updateUser', $event)" />
</template>v-model: Predictable Two-Way Bindings
Use defineModel by default for component bindings and emit updates on input. Only use the modelValue + update:modelValue pattern if you are on Vue < 3.4.
BAD:
<script setup>
const props = defineProps({ value: String })
</script>
<template>
<input :value="props.value" @input="$emit('input', $event.target.value)" />
</template>GOOD (Vue 3.4+):
<script setup>
const model = defineModel({ type: String })
</script>
<template>
<input v-model="model" />
</template>GOOD (Vue < 3.4):
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>If you need the updated value immediately after a change, use the input event value or nextTick in the parent.
Provide/Inject: Shared Context Without Prop Drilling
Use provide/inject for cross-tree state, but keep mutations centralized in the provider and expose explicit actions.
BAD:
// Provider.vue
provide('theme', reactive({ dark: false }))
// Consumer.vue
const theme = inject('theme')
// Mutating shared state from any depth becomes hard to track
theme.dark = trueGOOD:
// Provider.vue
const theme = reactive({ dark: false })
const toggleTheme = () => { theme.dark = !theme.dark }
provide(themeKey, readonly(theme))
provide(themeActionsKey, { toggleTheme })
// Consumer.vue
const theme = inject(themeKey)
const { toggleTheme } = inject(themeActionsKey)Use symbols for keys to avoid collisions in large apps:
export const themeKey = Symbol('theme')
export const themeActionsKey = Symbol('theme-actions')Use TypeScript Contracts for Public Component APIs
In TypeScript projects, type component boundaries directly with defineProps, defineEmits, and InjectionKey so invalid payloads and mismatched injections fail at compile time.
BAD:
<script setup lang="ts">
import { inject } from 'vue'
const props = defineProps({
userId: String
})
const emit = defineEmits(['save'])
const settings = inject('settings')
// Payload shape is not checked here
emit('save', 123)
// Key is string-based and not type-safe
settings?.theme = 'dark'
</script>GOOD:
<script setup lang="ts">
import { inject, provide } from 'vue'
import type { InjectionKey } from 'vue'
interface Props {
userId: string
}
interface Emits {
save: [payload: { id: string; draft: boolean }]
}
interface Settings {
theme: 'light' | 'dark'
}
const settingsKey: InjectionKey<Settings> = Symbol('settings')
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
provide(settingsKey, { theme: 'light' })
const settings = inject(settingsKey)
if (settings) {
emit('save', { id: props.userId, draft: false })
}
</script>Component Fallthrough Attributes Best Practices
Impact: MEDIUM - Fallthrough attributes are straightforward once you follow Vue's conventions: hyphenated names use bracket notation, listener keys are camelCase onX, and useAttrs() is current-but-not-reactive.
Task List
- Access hyphenated attribute names with bracket notation (for example
attrs['data-testid']) - Access event listeners with camelCase
onXkeys (for exampleattrs.onClick) - Do not
watch()values returned fromuseAttrs(); those watchers do not trigger on attr changes - Use
onUpdated()for attr-driven side effects - Promote frequently observed attrs to props when reactive observation is required
Access Attribute and Listener Keys Correctly
Hyphenated attribute names preserve their original casing in JavaScript, so dot notation does not work for keys that include -.
BAD:
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs.data-testid) // Syntax error
console.log(attrs.dataTestid) // undefined for data-testid
console.log(attrs['on-click']) // undefined
console.log(attrs['@click']) // undefined
</script>GOOD:
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs['data-testid'])
console.log(attrs['aria-label'])
console.log(attrs['foo-bar'])
console.log(attrs.onClick)
console.log(attrs.onCustomEvent)
console.log(attrs.onMouseEnter)
</script>Naming Reference
| Parent Usage | Access in attrs |
|---|---|
class="foo" | attrs.class |
data-id="123" | attrs['data-id'] |
aria-label="..." | attrs['aria-label'] |
foo-bar="baz" | attrs['foo-bar'] |
@click="fn" | attrs.onClick |
@custom-event="fn" | attrs.onCustomEvent |
@update:modelValue="fn" | attrs['onUpdate:modelValue'] |
useAttrs() Is Not Reactive
useAttrs() always reflects the latest values, but it is intentionally not reactive for watcher tracking.
BAD:
<script setup>
import { watch, watchEffect, useAttrs } from 'vue'
const attrs = useAttrs()
watch(
() => attrs.someAttr,
(newValue) => {
console.log('Changed:', newValue) // Never runs on attr changes
}
)
watchEffect(() => {
console.log(attrs.class) // Runs on setup, not on attr updates
})
</script>GOOD:
<script setup>
import { onUpdated, useAttrs } from 'vue'
const attrs = useAttrs()
onUpdated(() => {
console.log('Latest attrs:', attrs)
})
</script>GOOD:
<script setup>
import { watch } from 'vue'
const props = defineProps({
someAttr: String
})
watch(
() => props.someAttr,
(newValue) => {
console.log('Changed:', newValue)
}
)
</script>Common Patterns
Check for optional attrs safely
<script setup>
import { computed, useAttrs } from 'vue'
const attrs = useAttrs()
const hasTestId = computed(() => 'data-testid' in attrs)
const ariaLabel = computed(() => attrs['aria-label'] ?? 'Default label')
</script>Forward listeners after internal logic
<script setup>
import { useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
function handleClick(event) {
console.log('Internal handling first')
attrs.onClick?.(event)
}
</script>
<template>
<button @click="handleClick">
<slot />
</button>
</template>TypeScript Notes
useAttrs() is typed as Record<string, unknown>, so cast individual keys when needed.
<script setup lang="ts">
import { useAttrs } from 'vue'
const attrs = useAttrs()
const testId = attrs['data-testid'] as string | undefined
const onClick = attrs.onClick as ((event: MouseEvent) => void) | undefined
</script>KeepAlive Component Best Practices
Impact: HIGH - <KeepAlive> caches component instances instead of destroying them. Use it to preserve state across switches, but manage cache size and freshness explicitly to avoid memory growth or stale UI.
Task List
- Use KeepAlive only where state preservation improves UX
- Set a reasonable
maxto cap cache size - Declare component names for include/exclude matching
- Use
onActivated/onDeactivatedfor cache-aware logic - Decide how and when cached views refresh their data
- Avoid caching memory-heavy or security-sensitive views
When to Use KeepAlive
Use KeepAlive when switching between views where state should persist (tabs, multi-step forms, dashboards). Avoid it when each visit should start fresh.
BAD:
<template>
<!-- State resets on every switch -->
<component :is="currentTab" />
</template>GOOD:
<template>
<!-- State preserved between switches -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
</template>When NOT to Use KeepAlive
- Search or filter pages where users expect fresh results
- Memory-heavy components (maps, large tables, media players)
- Sensitive flows where data must be cleared on exit
- Components with heavy background activity you cannot pause
Limit and Control the Cache
Always cap cache size with max and restrict caching to specific components when possible.
<template>
<KeepAlive :max="5" include="Dashboard,Settings">
<component :is="currentView" />
</KeepAlive>
</template>Ensure Component Names Match include/exclude
include and exclude match the component name option. Explicitly set names for reliable caching.
<!-- TabA.vue -->
<script setup>
defineOptions({ name: 'TabA' })
</script><template>
<KeepAlive include="TabA,TabB">
<component :is="currentTab" />
</KeepAlive>
</template>Cache Invalidation Strategies
Vue 3 has no direct API to remove a specific cached instance. Use keys or dynamic include/exclude to force refreshes.
<script setup>
import { ref, reactive } from 'vue'
const currentView = ref('Dashboard')
const viewKeys = reactive({ Dashboard: 0, Settings: 0 })
function invalidateCache(view) {
viewKeys[view]++
}
</script>
<template>
<KeepAlive>
<component :is="currentView" :key="`${currentView}-${viewKeys[currentView]}`" />
</KeepAlive>
</template>Lifecycle Hooks for Cached Components
Cached components are not destroyed on switch. Use activation hooks for refresh and cleanup.
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
refreshData()
})
onDeactivated(() => {
pauseTimers()
})
</script>Router Caching and Freshness
Decide whether navigation should show cached state or a fresh view. A common pattern is to key by route when params change.
<template>
<router-view v-slot="{ Component, route }">
<KeepAlive>
<component :is="Component" :key="route.fullPath" />
</KeepAlive>
</router-view>
</template>If you want cache reuse but fresh data, refresh in onActivated and compare query/params before fetching.
Component Slots Best Practices
Impact: MEDIUM - Slots are a core component API surface in Vue. Structure them intentionally so templates stay predictable, typed, and performant.
Task List
- Use shorthand syntax for named slots (
#instead ofv-slot:) - Render optional slot wrapper elements only when slot content exists (
$slotschecks) - Type scoped slot contracts with
defineSlotsin TypeScript components - Provide fallback content for optional slots
- Prefer composables over renderless components for pure logic reuse
Shorthand syntax for named slots
BAD:
<MyComponent>
<template v-slot:header> ... </template>
</MyComponent>GOOD:
<MyComponent>
<template #header> ... </template>
</MyComponent>Conditionally Render Optional Slot Wrappers
Use $slots checks when wrapper elements add spacing, borders, or layout constraints.
BAD:
<!-- Card.vue -->
<template>
<article class="card">
<header class="card-header">
<slot name="header" />
</header>
<section class="card-body">
<slot />
</section>
<footer class="card-footer">
<slot name="footer" />
</footer>
</article>
</template>GOOD:
<!-- Card.vue -->
<template>
<article class="card">
<header v-if="$slots.header" class="card-header">
<slot name="header" />
</header>
<section v-if="$slots.default" class="card-body">
<slot />
</section>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</article>
</template>Type Scoped Slot Props with defineSlots
In <script setup lang="ts">, use defineSlots so slot consumers get autocomplete and static checks.
BAD:
<!-- ProductList.vue -->
<script setup lang="ts">
interface Product {
id: number
name: string
}
defineProps<{ products: Product[] }>()
</script>
<template>
<ul>
<li v-for="(product, index) in products" :key="product.id">
<slot :product="product" :index="index" />
</li>
</ul>
</template>GOOD:
<!-- ProductList.vue -->
<script setup lang="ts">
interface Product {
id: number
name: string
}
defineProps<{ products: Product[] }>()
defineSlots<{
default(props: { product: Product; index: number }): any
empty(): any
}>()
</script>
<template>
<ul v-if="products.length">
<li v-for="(product, index) in products" :key="product.id">
<slot :product="product" :index="index" />
</li>
</ul>
<slot v-else name="empty" />
</template>Provide Slot Fallback Content
Fallback content makes components resilient when parents omit optional slots.
BAD:
<!-- SubmitButton.vue -->
<template>
<button type="submit" class="btn-primary">
<slot />
</button>
</template>GOOD:
<!-- SubmitButton.vue -->
<template>
<button type="submit" class="btn-primary">
<slot>Submit</slot>
</button>
</template>Prefer Composables for Pure Logic Reuse
Renderless components are still useful for slot-driven composition, but composables are usually cleaner for logic-only reuse.
BAD:
<!-- MouseTracker.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
function onMove(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
</script>
<template>
<slot :x="x" :y="y" />
</template>GOOD:
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function onMove(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
return { x, y }
}<!-- MousePosition.vue -->
<script setup lang="ts">
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<p>{{ x }}, {{ y }}</p>
</template>Suspense Component Best Practices
Impact: MEDIUM - <Suspense> coordinates async dependencies (async components or async setup) and renders a fallback while they resolve. Misconfiguration leads to missing loading states, empty renders, or subtle UX bugs.
Task List
- Wrap default and fallback slot content in a single root node
- Use
timeoutwhen you need the fallback to appear on reverts - Force root replacement with
:keywhen you need Suspense to re-trigger - Add
suspensibleto nested Suspense boundaries (Vue 3.3+) - Use
@pending,@resolve, and@fallbackfor programmatic loading state - Nest
RouterView->Transition->KeepAlive->Suspensein that order - Keep Suspense usage centralized and documented in production
Single Root in Default and Fallback Slots
Suspense tracks a single immediate child in both slots. Wrap multiple elements in a single element or component.
BAD:
<template>
<Suspense>
<AsyncHeader />
<AsyncList />
<template #fallback>
<LoadingSpinner />
<LoadingHint />
</template>
</Suspense>
</template>GOOD:
<template>
<Suspense>
<div>
<AsyncHeader />
<AsyncList />
</div>
<template #fallback>
<div>
<LoadingSpinner />
<LoadingHint />
</div>
</template>
</Suspense>
</template>Fallback Timing on Reverts (timeout)
When Suspense is already resolved and new async work starts, the previous content remains visible until the timeout elapses. Use timeout="0" for immediate fallback or a short delay to avoid flicker.
BAD:
<template>
<Suspense>
<component :is="currentView" :key="viewKey" />
<template #fallback>
Loading...
</template>
</Suspense>
</template>GOOD:
<template>
<Suspense :timeout="200">
<component :is="currentView" :key="viewKey" />
<template #fallback>
Loading...
</template>
</Suspense>
</template>Pending State Only Re-triggers on Root Replacement
Once resolved, Suspense only re-enters pending when the root node of the default slot changes. If async work happens deeper in the tree, no fallback appears.
BAD:
<template>
<Suspense>
<TabContainer>
<AsyncDashboard v-if="tab === 'dashboard'" />
<AsyncSettings v-else />
</TabContainer>
<template #fallback>
Loading...
</template>
</Suspense>
</template>GOOD:
<template>
<Suspense>
<component :is="tabs[tab]" :key="tab" />
<template #fallback>
Loading...
</template>
</Suspense>
</template>Use suspensible for Nested Suspense (Vue 3.3+)
Nested Suspense boundaries need suspensible on the inner boundary so the parent can coordinate loading state. Without it, inner async content may render empty nodes until resolved.
BAD:
<template>
<Suspense>
<LayoutShell>
<Suspense>
<AsyncWidget />
<template #fallback>Loading widget...</template>
</Suspense>
</LayoutShell>
<template #fallback>Loading layout...</template>
</Suspense>
</template>GOOD:
<template>
<Suspense>
<LayoutShell>
<Suspense suspensible>
<AsyncWidget />
<template #fallback>Loading widget...</template>
</Suspense>
</LayoutShell>
<template #fallback>Loading layout...</template>
</Suspense>
</template>Track Loading with Suspense Events
Use @pending, @resolve, and @fallback for analytics, global loading indicators, or coordinating UI outside the Suspense boundary.
<script setup>
import { ref } from 'vue'
const isLoading = ref(false)
const onPending = () => {
isLoading.value = true
}
const onResolve = () => {
isLoading.value = false
}
</script>
<template>
<LoadingBar v-if="isLoading" />
<Suspense @pending="onPending" @resolve="onResolve">
<AsyncPage />
<template #fallback>
<PageSkeleton />
</template>
</Suspense>
</template>Recommended Nesting with RouterView, Transition, KeepAlive
When combining these components, the nesting order should be RouterView -> Transition -> KeepAlive -> Suspense so each wrapper works correctly.
BAD:
<template>
<RouterView v-slot="{ Component }">
<Suspense>
<KeepAlive>
<Transition mode="out-in">
<component :is="Component" />
</Transition>
</KeepAlive>
</Suspense>
</RouterView>
</template>GOOD:
<template>
<RouterView v-slot="{ Component }">
<Transition mode="out-in">
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>Loading...</template>
</Suspense>
</KeepAlive>
</Transition>
</RouterView>
</template>Treat Suspense Cautiously in Production
In production code, keep Suspense boundaries minimal, document where they are used, and have a fallback loading strategy if you ever need to replace or refactor them.
Teleport Component Best Practices
Impact: MEDIUM - <Teleport> renders part of a component's template in a different place in the DOM while preserving the Vue component hierarchy. Use it for overlays (modals, toasts, tooltips) or any UI that must escape stacking contexts, overflow, or fixed positioning constraints.
Task List
- Teleport overlays to
bodyor a dedicated container outside the app root - Keep a shared target for similar UI (
#modals,#notifications) and control layering with order or z-index - Use
:disabledfor responsive layouts that should render inline on small screens - Remember props, emits, and provide/inject still work through teleport
- Avoid relying on parent stacking contexts or transforms for teleported UI
Teleport Overlays Out of Transformed Containers
When an ancestor has transform, filter, or perspective, fixed-position overlays can behave like they are locally positioned. Teleport escapes that context.
BAD:
<template>
<div class="animated-container">
<button @click="open = true">Open</button>
<!-- Broken: fixed positioning is scoped to the transformed parent -->
<div v-if="open" class="modal">Modal</div>
</div>
</template>
<style>
.animated-container {
transform: translateZ(0);
}
.modal {
position: fixed;
inset: 0;
z-index: 9999;
}
</style>GOOD:
<template>
<div class="animated-container">
<button @click="open = true">Open</button>
<Teleport to="body">
<div v-if="open" class="modal">Modal</div>
</Teleport>
</div>
</template>Responsive Layouts with disabled
Use :disabled to render inline on mobile and teleport on larger screens:
<script setup>
import { useMediaQuery } from '@vueuse/core'
const isMobile = useMediaQuery('(max-width: 768px)')
</script>
<template>
<Teleport to="body" :disabled="isMobile">
<nav class="sidebar">Navigation</nav>
</Teleport>
</template>Logical Hierarchy Is Preserved
Teleport changes DOM position, not the Vue component tree. Props, emits, slots, and provide/inject still work:
<template>
<Teleport to="body">
<ChildPanel :message="message" @close="open = false" />
</Teleport>
</template>Multiple Teleports to the Same Target
Teleports to the same target append in declaration order:
<template>
<Teleport to="#notifications">
<div>First</div>
</Teleport>
<Teleport to="#notifications">
<div>Second</div>
</Teleport>
</template>Use a shared container to keep stacking predictable, and apply z-index only when you need explicit layering.
TransitionGroup Component Best Practices
Impact: MEDIUM - <TransitionGroup> animates lists of items entering, leaving, and moving. Use it for v-for lists or dynamic collections where individual items change over time.
Task List
- Use
<TransitionGroup>only for lists and repeated items - Provide unique, stable keys for every direct child
- Use
tagwhen you need semantic or layout wrappers - Avoid the
modeprop (not supported) - Use JavaScript hooks for staggered effects
Use TransitionGroup for Lists
<TransitionGroup> is designed for list items. Use tag to control the wrapper element when needed.
BAD:
<template>
<TransitionGroup name="fade">
<ComponentA />
<ComponentB />
</TransitionGroup>
</template>GOOD:
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</TransitionGroup>
</template>Always Provide Stable Keys
Keys are required. Without stable keys, Vue cannot track item positions and animations break.
BAD:
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="(item, index) in items" :key="index">
{{ item.name }}
</li>
</TransitionGroup>
</template>GOOD:
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</TransitionGroup>
</template>Do Not Use mode on TransitionGroup
mode is only for <Transition> because it swaps a single element. Use <Transition> if you need in/out sequencing.
BAD:
<template>
<TransitionGroup name="list" tag="div" mode="out-in">
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
</TransitionGroup>
</template>GOOD:
<template>
<Transition name="fade" mode="out-in">
<component :is="currentView" :key="currentView" />
</Transition>
</template>Stagger List Animations with Data Attributes
For cascading list animations, pass the index to JavaScript hooks and compute delay per item.
<template>
<TransitionGroup
tag="ul"
:css="false"
@before-enter="onBeforeEnter"
@enter="onEnter"
>
<li v-for="(item, index) in items" :key="item.id" :data-index="index">
{{ item.name }}
</li>
</TransitionGroup>
</template>
<script setup>
function onBeforeEnter(el) {
el.style.opacity = 0
el.style.transform = 'translateY(12px)'
}
function onEnter(el, done) {
const delay = Number(el.dataset.index) * 80
setTimeout(() => {
el.style.transition = 'all 0.25s ease'
el.style.opacity = 1
el.style.transform = 'translateY(0)'
setTimeout(done, 250)
}, delay)
}
</script>Transition Component Best Practices
Impact: MEDIUM - <Transition> animates entering/leaving of a single element or component. It is ideal for toggling UI states, swapping views, or animating one component at a time.
Task List
- Wrap a single element or component inside
<Transition> - Provide a
keywhen switching between same element types - Use
mode="out-in"when you need sequential swaps - Prefer
transformandopacityfor smooth animations
Use Transition for a Single Root Element
<Transition> only supports one direct child. Wrap multiple nodes in a single element or component.
BAD:
<template>
<Transition name="fade">
<h3>Title</h3>
<p>Description</p>
</Transition>
</template>GOOD:
<template>
<Transition name="fade">
<div>
<h3>Title</h3>
<p>Description</p>
</div>
</Transition>
</template>Force Transitions Between Same Element Types
Vue reuses the same DOM element when the tag type does not change. Add key so Vue treats it as a new element and triggers enter/leave.
BAD:
<template>
<Transition name="fade">
<p v-if="isActive">Active</p>
<p v-else>Inactive</p>
</Transition>
</template>GOOD:
<template>
<Transition name="fade" mode="out-in">
<p v-if="isActive" key="active">Active</p>
<p v-else key="inactive">Inactive</p>
</Transition>
</template>Use mode to Avoid Overlap During Swaps
When swapping components or views, use mode="out-in" to prevent both from being visible at the same time.
BAD:
<template>
<Transition name="fade">
<component :is="currentView" />
</Transition>
</template>GOOD:
<template>
<Transition name="fade" mode="out-in">
<component :is="currentView" :key="currentView" />
</Transition>
</template>Animate transform and opacity for Performance
Avoid layout-triggering properties such as height, margin, or top. Use transform and opacity for smooth, GPU-friendly transitions.
BAD:
.slide-enter-active,
.slide-leave-active {
transition: height 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
height: 0;
}GOOD:
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from {
transform: translateX(-12px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(12px);
opacity: 0;
}Composable Organization Patterns
Impact: MEDIUM - Treat composables as reusable, stateful building blocks and keep their code organized by feature concern. This keeps large components maintainable and prevents hard-to-debug mutation and API design issues.
Task List
- Compose complex behavior from small, focused composables
- Use options objects for composables with multiple optional parameters
- Return readonly state when updates must flow through explicit actions
- Keep pure utility functions as plain utilities, not composables
- Organize composable and component code by feature concern, and extract composables when components grow
Compose Composables from Smaller Primitives
BAD:
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
const inside = ref(false)
const el = ref(null)
function onMove(e) {
x.value = e.pageX
y.value = e.pageY
if (!el.value) return
const r = el.value.getBoundingClientRect()
inside.value = x.value >= r.left && x.value <= r.right &&
y.value >= r.top && y.value <= r.bottom
}
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
</script>GOOD:
// composables/useEventListener.js
import { onMounted, onUnmounted, toValue } from 'vue'
export function useEventListener(target, event, callback) {
onMounted(() => toValue(target).addEventListener(event, callback))
onUnmounted(() => toValue(target).removeEventListener(event, callback))
}// composables/useMouse.js
import { ref } from 'vue'
import { useEventListener } from './useEventListener'
export function useMouse() {
const x = ref(0)
const y = ref(0)
useEventListener(window, 'mousemove', (e) => {
x.value = e.pageX
y.value = e.pageY
})
return { x, y }
}// composables/useMouseInElement.js
import { computed } from 'vue'
import { useMouse } from './useMouse'
export function useMouseInElement(elementRef) {
const { x, y } = useMouse()
const isOutside = computed(() => {
if (!elementRef.value) return true
const rect = elementRef.value.getBoundingClientRect()
return x.value < rect.left || x.value > rect.right ||
y.value < rect.top || y.value > rect.bottom
})
return { x, y, isOutside }
}Use Options Object Pattern for Composable Parameters
BAD:
export function useFetch(url, method, headers, timeout, retries, immediate) {
// hard to read and easy to misorder
}
useFetch('/api/users', 'GET', null, 5000, 3, true)GOOD:
export function useFetch(url, options = {}) {
const {
method = 'GET',
headers = {},
timeout = 30000,
retries = 0,
immediate = true
} = options
// implementation
return { method, headers, timeout, retries, immediate }
}
useFetch('/api/users', {
method: 'POST',
timeout: 5000,
retries: 3
})interface UseCounterOptions {
initial?: number
min?: number
max?: number
step?: number
}
export function useCounter(options: UseCounterOptions = {}) {
const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options
// implementation
}Return Readonly State with Explicit Actions
BAD:
export function useCart() {
const items = ref([])
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
return { items, total } // any consumer can mutate directly
}
const { items } = useCart()
items.value.push({ id: 1, price: 10 })GOOD:
import { ref, computed, readonly } from 'vue'
export function useCart() {
const _items = ref([])
const total = computed(() =>
_items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(product, quantity = 1) {
const existing = _items.value.find(item => item.id === product.id)
if (existing) {
existing.quantity += quantity
return
}
_items.value.push({ ...product, quantity })
}
function removeItem(productId) {
_items.value = _items.value.filter(item => item.id !== productId)
}
return {
items: readonly(_items),
total,
addItem,
removeItem
}
}Keep Utilities as Utilities
BAD:
export function useFormatters() {
const formatDate = (date) => new Intl.DateTimeFormat('en-US').format(date)
const formatCurrency = (amount) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
return { formatDate, formatCurrency }
}
const { formatDate } = useFormatters()GOOD:
// utils/formatters.js
export function formatDate(date) {
return new Intl.DateTimeFormat('en-US').format(date)
}
export function formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount)
}// composables/useInvoiceSummary.js
import { computed } from 'vue'
import { formatCurrency } from '@/utils/formatters'
export function useInvoiceSummary(invoiceRef) {
const totalLabel = computed(() => formatCurrency(invoiceRef.value.total))
return { totalLabel }
}Organize Composable and Component Code by Feature Concern
BAD:
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const searchQuery = ref('')
const items = ref([])
const selected = ref(null)
const showModal = ref(false)
const sortBy = ref('name')
const filter = ref('all')
const loading = ref(false)
const filtered = computed(() => items.value.filter(i => i.category === filter.value))
function openModal() { showModal.value = true }
const sorted = computed(() => [...filtered.value].sort(/* ... */))
watch(searchQuery, () => { /* ... */ })
onMounted(() => { /* ... */ })
</script>GOOD:
<script setup>
import { useItems } from '@/composables/useItems'
import { useSearch } from '@/composables/useSearch'
import { useSelectionModal } from '@/composables/useSelectionModal'
// Data
const { items, loading, fetchItems } = useItems()
// Search/filter/sort
const { query, visibleItems } = useSearch(items)
// Selection + modal
const { selectedItem, isModalOpen, selectItem, closeModal } = useSelectionModal()
</script>// composables/useItems.js
import { ref, onMounted } from 'vue'
export function useItems() {
const items = ref([])
const loading = ref(false)
async function fetchItems() {
loading.value = true
try {
items.value = await api.getItems()
} finally {
loading.value = false
}
}
onMounted(fetchItems)
return { items, loading, fetchItems }
}Directive Best Practices
Impact: MEDIUM - Directives are for low-level DOM access. Use them sparingly, keep them side-effect safe, and prefer components or composables when you need stateful or reusable UI behavior.
Task List
- Use directives only when you need direct DOM access
- Do not mutate directive arguments or binding objects
- Clean up timers, listeners, and observers in
unmounted - Register directives in
<script setup>with thev-prefix - In TypeScript projects, type directive values and augment template directive types
- Prefer components or composables for complex behavior
Treat Directive Arguments as Read-Only
Directive bindings are not reactive storage. Don’t write to them.
const vFocus = {
mounted(el, binding) {
// binding.value is read-only
el.focus()
}
}Avoid Directives on Components
Directives apply to DOM elements. When used on components, they attach to the root element and can break if the root changes.
BAD:
<MyInput v-focus />GOOD:
<!-- MyInput.vue -->
<script setup>
const vFocus = (el) => el.focus()
</script>
<template>
<input v-focus />
</template>Clean Up Side Effects in unmounted
Any timers, listeners, or observers must be removed to avoid leaks.
const vResize = {
mounted(el) {
const observer = new ResizeObserver(() => {})
observer.observe(el)
el._observer = observer
},
unmounted(el) {
el._observer?.disconnect()
}
}Prefer Function Shorthand for Single-Hook Directives
If you only need mounted/updated, use the function form.
const vAutofocus = (el) => el.focus()Use the v- Prefix and Script Setup Registration
<script setup>
const vFocus = (el) => el.focus()
</script>
<template>
<input v-focus />
</template>Type Custom Directives in TypeScript Projects
Use Directive<Element, ValueType> so binding.value is typed, and augment Vue's template types so directives are recognized in SFC templates.
BAD:
// Untyped directive value and no template type augmentation
export const vHighlight = {
mounted(el, binding) {
el.style.backgroundColor = binding.value
}
}GOOD:
import type { Directive } from 'vue'
type HighlightValue = string
export const vHighlight = {
mounted(el, binding) {
el.style.backgroundColor = binding.value
}
} satisfies Directive<HTMLElement, HighlightValue>
declare module 'vue' {
interface ComponentCustomProperties {
vHighlight: typeof vHighlight
}
}Handle SSR with getSSRProps
Directive hooks such as mounted and updated do not run during SSR. If a directive sets attributes/classes that affect rendered HTML, provide an SSR equivalent via getSSRProps to avoid hydration mismatches.
BAD:
const vTooltip = {
mounted(el, binding) {
el.setAttribute('data-tooltip', binding.value)
el.classList.add('has-tooltip')
}
}GOOD:
const vTooltip = {
mounted(el, binding) {
el.setAttribute('data-tooltip', binding.value)
el.classList.add('has-tooltip')
},
getSSRProps(binding) {
return {
'data-tooltip': binding.value,
class: 'has-tooltip'
}
}
}Prefer Declarative Templates When Possible
If a standard attribute or binding works, use it instead of a directive.
Decide Between Directives and Components
Use a directive for DOM-level behavior. Use a component when behavior affects structure, state, or rendering.
Avoid Excessive Component Abstraction in Large Lists
Impact: MEDIUM - Component instances are more expensive than plain DOM nodes. While abstractions improve code organization, unnecessary nesting creates overhead. In large lists, this overhead multiplies - 100 items with 3 levels of abstraction means 300+ component instances instead of 100.
Don't avoid abstraction entirely, but be mindful of component depth in frequently-rendered elements like list items.
Task List
- Review list item components for unnecessary wrapper components
- Consider flattening component hierarchies in hot paths
- Use native elements when a component adds no value
- Profile component counts using Vue DevTools
- Focus optimization efforts on the most-rendered components
BAD:
<!-- BAD: Deep abstraction in list items -->
<template>
<div class="user-list">
<!-- For 100 users: Creates 400 component instances -->
<UserCard v-for="user in users" :key="user.id" :user="user" />
</div>
</template>
<!-- UserCard.vue -->
<template>
<Card> <!-- Wrapper component #1 -->
<CardHeader> <!-- Wrapper component #2 -->
<UserAvatar :src="user.avatar" /> <!-- Wrapper component #3 -->
</CardHeader>
<CardBody> <!-- Wrapper component #4 -->
<Text>{{ user.name }}</Text>
</CardBody>
</Card>
</template>
<!-- Each UserCard creates: Card + CardHeader + CardBody + UserAvatar + Text
100 users = 500+ component instances -->GOOD:
<!-- GOOD: Flattened structure in list items -->
<template>
<div class="user-list">
<!-- For 100 users: Creates 100 component instances -->
<UserCard v-for="user in users" :key="user.id" :user="user" />
</div>
</template>
<!-- UserCard.vue - Flattened, uses native elements -->
<template>
<div class="card">
<div class="card-header">
<img :src="user.avatar" :alt="user.name" class="avatar" />
</div>
<div class="card-body">
<span class="user-name">{{ user.name }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
user: Object
})
</script>
<style scoped>
/* Styles that would have been in Card, CardHeader, etc. */
.card { /* ... */ }
.card-header { /* ... */ }
.card-body { /* ... */ }
.avatar { /* ... */ }
</style>When Abstraction Is Still Worth It
<!-- Component abstraction is valuable when: -->
<!-- 1. Complex behavior is encapsulated -->
<UserStatusIndicator :user="user" /> <!-- Has logic, tooltips, etc. -->
<!-- 2. Reused outside of the hot path -->
<Card> <!-- OK to use in one-off places, not in 100-item lists -->
<!-- 3. The list itself is small -->
<template v-if="items.length < 20">
<FancyItem v-for="item in items" :key="item.id" />
</template>
<!-- 4. Virtualization is used (only ~20 items rendered at once) -->
<RecycleScroller :items="items">
<template #default="{ item }">
<ComplexItem :item="item" /> <!-- OK - only 20 instances exist -->
</template>
</RecycleScroller>Measuring Component Overhead
// In development, profile component counts
import { onMounted, getCurrentInstance } from 'vue'
onMounted(() => {
const instance = getCurrentInstance()
let count = 0
function countComponents(vnode) {
if (vnode.component) count++
if (vnode.children) {
vnode.children.forEach(child => {
if (child.component || child.children) countComponents(child)
})
}
}
// Use Vue DevTools instead for accurate counts
console.log('Check Vue DevTools Components tab for instance counts')
})Alternatives to Wrapper Components
<!-- Instead of a <Button> component for styling: -->
<button class="btn btn-primary">Click</button>
<!-- Instead of a <Text> component: -->
<span class="text-body">{{ content }}</span>
<!-- Instead of layout wrapper components in lists: -->
<div class="flex items-center gap-2">
<!-- content -->
</div>
<!-- Use CSS classes or Tailwind instead of component abstractions for styling -->Impact Calculation
| List Size | Components per Item | Total Instances | Memory Impact |
|---|---|---|---|
| 100 items | 1 (flat) | 100 | Baseline |
| 100 items | 3 (nested) | 300 | ~3x memory |
| 100 items | 5 (deeply nested) | 500 | ~5x memory |
| 1000 items | 1 (flat) | 1000 | High |
| 1000 items | 5 (deeply nested) | 5000 | Very High |
Use v-once and v-memo to Skip Unnecessary Updates
Impact: MEDIUM - Vue re-evaluates templates on every reactive change. For content that never changes or changes infrequently, v-once and v-memo tell Vue to skip updates, reducing render work.
Use v-once for truly static content and v-memo for conditionally-static content in lists.
Task List
- Apply
v-onceto elements that use runtime data but never need updating - Apply
v-memoto list items that should only update on specific condition changes - Verify memoized content doesn't need to respond to other state changes
- Profile with Vue DevTools to confirm update skipping
v-once: Render Once, Never Update
BAD:
<template>
<!-- BAD: Re-evaluated on every parent re-render -->
<div class="terms-content">
<h1>Terms of Service</h1>
<p>Version: {{ termsVersion }}</p>
<div v-html="termsContent"></div>
</div>
<!-- This content NEVER changes, but Vue checks it every render -->
<footer>
<p>Copyright {{ copyrightYear }} {{ companyName }}</p>
</footer>
</template>GOOD:
<template>
<!-- GOOD: Rendered once, skipped on all future updates -->
<div class="terms-content" v-once>
<h1>Terms of Service</h1>
<p>Version: {{ termsVersion }}</p>
<div v-html="termsContent"></div>
</div>
<!-- v-once tells Vue this never needs to update -->
<footer v-once>
<p>Copyright {{ copyrightYear }} {{ companyName }}</p>
</footer>
</template>
<script setup>
// These values are set once at component creation
const termsVersion = '2.1'
const termsContent = fetchedTermsHTML
const copyrightYear = 2024
const companyName = 'Acme Corp'
</script>v-memo: Conditional Memoization for Lists
BAD:
<template>
<!-- BAD: All items re-render when selectedId changes -->
<div v-for="item in list" :key="item.id">
<div :class="{ selected: item.id === selectedId }">
<ExpensiveComponent :data="item" />
</div>
</div>
</template>GOOD:
<template>
<!-- GOOD: Items only re-render when their selection state changes -->
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id === selectedId]"
>
<div :class="{ selected: item.id === selectedId }">
<ExpensiveComponent :data="item" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const list = ref([/* many items */])
const selectedId = ref(null)
// When selectedId changes:
// - Only the previously-selected item re-renders (selected: true -> false)
// - Only the newly-selected item re-renders (selected: false -> true)
// - All other items are SKIPPED (v-memo values unchanged)
</script>v-memo with Multiple Dependencies
<template>
<!-- Re-render only when item's selection OR editing state changes -->
<div
v-for="item in items"
:key="item.id"
v-memo="[item.id === selectedId, item.id === editingId]"
>
<ItemCard
:item="item"
:selected="item.id === selectedId"
:editing="item.id === editingId"
/>
</div>
</template>
<script setup>
const selectedId = ref(null)
const editingId = ref(null)
const items = ref([/* ... */])
</script>v-memo with Empty Array = v-once
<template>
<!-- v-memo="[]" is equivalent to v-once -->
<div v-for="item in staticList" :key="item.id" v-memo="[]">
{{ item.name }}
</div>
</template>When NOT to Use These Directives
<template>
<!-- DON'T: Content that DOES need to update -->
<div v-once>
<span>Count: {{ count }}</span> <!-- count won't update! -->
</div>
<!-- DON'T: When child components have their own reactive state -->
<div v-memo="[selected]">
<InputField v-model="item.name" /> <!-- v-model won't work properly -->
</div>
<!-- DON'T: When the memoization benefit is minimal -->
<span v-once>{{ simpleText }}</span> <!-- Overhead not worth it -->
</template>Performance Comparison
| Scenario | Without Directive | With v-once/v-memo |
|---|---|---|
| Static header, parent re-renders 100x | Re-evaluated 100x | Evaluated 1x |
| 1000 items, selection changes | 1000 items re-render | 2 items re-render |
| Complex child component | Full re-render | Skipped if memoized |
Debugging Memoized Components
<script setup>
import { onUpdated } from 'vue'
// This won't fire if v-memo prevents update
onUpdated(() => {
console.log('Component updated')
})
</script>Virtualize Large Lists to Avoid DOM Overload
Impact: HIGH - Rendering all items in a large list (hundreds or thousands) creates massive amounts of DOM nodes. Each node consumes memory, slows down initial render, and makes updates expensive. List virtualization only renders visible items, dramatically improving performance.
Use a virtualization library when dealing with lists that could exceed 50-100 items, especially if items have complex content.
Task List
- Identify lists that render more than 50-100 items
- Install a virtualization library (vue-virtual-scroller, @tanstack/vue-virtual)
- Replace standard
v-forwith virtualized component - Ensure list items have consistent or estimable heights
- Test with realistic data volumes during development
Recommended Libraries
| Library | Best For | Notes |
|---|---|---|
vue-virtual-scroller | General use, easy setup | Most popular, good defaults |
@tanstack/vue-virtual | Complex layouts, headless | Framework-agnostic, flexible |
vue-virtual-scroll-grid | Grid layouts | 2D virtualization |
vueuc/VVirtualList | Naive UI projects | Part of Naive UI ecosystem |
BAD:
<template>
<!-- BAD: Renders ALL 10,000 items immediately -->
<div class="user-list">
<UserCard
v-for="user in users"
:key="user.id"
:user="user"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import UserCard from './UserCard.vue'
const users = ref([])
onMounted(async () => {
// 10,000 DOM nodes created, browser struggles
users.value = await fetchAllUsers()
})
</script>GOOD:
<template>
<!-- GOOD: Only renders ~20 visible items at a time -->
<RecycleScroller
class="user-list"
:items="users"
:item-size="80"
key-field="id"
v-slot="{ item }"
>
<UserCard :user="item" />
</RecycleScroller>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import UserCard from './UserCard.vue'
const users = ref([])
onMounted(async () => {
// 10,000 items in memory, but only ~20 DOM nodes
users.value = await fetchAllUsers()
})
</script>
<style scoped>
.user-list {
height: 600px; /* Container must have fixed height */
}
</style>Using @tanstack/vue-virtual
<template>
<div ref="parentRef" class="list-container">
<div
:style="{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative'
}"
>
<div
v-for="virtualRow in rowVirtualizer.getVirtualItems()"
:key="virtualRow.key"
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}"
>
<UserCard :user="users[virtualRow.index]" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
const users = ref([/* 10,000 users */])
const parentRef = ref(null)
const rowVirtualizer = useVirtualizer({
count: users.value.length,
getScrollElement: () => parentRef.value,
estimateSize: () => 80, // Estimated row height
overscan: 5 // Render 5 extra items above/below viewport
})
</script>
<style scoped>
.list-container {
height: 600px;
overflow: auto;
}
</style>Dynamic Heights with vue-virtual-scroller
<template>
<!-- For variable height items, use DynamicScroller -->
<DynamicScroller
:items="messages"
:min-item-size="54"
key-field="id"
>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="index"
>
<ChatMessage :message="item" />
</DynamicScrollerItem>
</template>
</DynamicScroller>
</template>
<script setup>
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
</script>Performance Comparison
| Approach | 100 Items | 1,000 Items | 10,000 Items |
|---|---|---|---|
| Regular v-for | ~100 DOM nodes | ~1,000 DOM nodes | ~10,000 DOM nodes |
| Virtualized | ~20 DOM nodes | ~20 DOM nodes | ~20 DOM nodes |
| Initial render | Fast | Slow | Very slow / crashes |
| Virtualized render | Fast | Fast | Fast |
When NOT to Virtualize
- Lists under 50 items with simple content
- Lists where all items must be accessible to screen readers simultaneously
- Print layouts where all content must render
- SEO-critical content that must be in initial HTML
Vue Plugin Best Practices
Impact: MEDIUM - Vue plugins should follow the app.use() contract, expose explicit capabilities, and use collision-safe injection keys. This keeps plugin setup predictable and composable across large apps.
Task List
- Export plugins as an object with
install()or as an install function - Use the
appinstance ininstall()to register components/directives/provides - Type plugin APIs with
Plugin(and options tuple types when needed) - Use symbol keys (prefer
InjectionKey<T>) forprovide/injectin plugins - Add a small typed composable wrapper for required injections to fail fast
Structure Plugins for app.use()
A Vue plugin must be either:
- An object with
install(app, options?) - A function with the same signature
BAD:
const notAPlugin = {
doSomething() {}
}
app.use(notAPlugin)GOOD:
import type { App } from 'vue'
interface PluginOptions {
prefix?: string
debug?: boolean
}
const myPlugin = {
install(app: App, options: PluginOptions = {}) {
const { prefix = 'my', debug = false } = options
if (debug) {
console.log('Installing myPlugin with prefix:', prefix)
}
app.provide('myPlugin', { prefix })
}
}
app.use(myPlugin, { prefix: 'custom', debug: true })GOOD:
import type { App } from 'vue'
function simplePlugin(app: App, options?: { message: string }) {
app.config.globalProperties.$greet = () => options?.message ?? 'Hello!'
}
app.use(simplePlugin, { message: 'Welcome!' })Register Capabilities Explicitly in install()
Inside install(), wire behavior through Vue application APIs:
app.component()for global componentsapp.directive()for global directivesapp.provide()for injectable services and configapp.config.globalPropertiesfor optional global helpers (sparingly)
BAD:
const uselessPlugin = {
install(app, options) {
const service = createService(options)
}
}GOOD:
const usefulPlugin = {
install(app, options) {
const service = createService(options)
app.provide(serviceKey, service)
}
}Type Plugin Contracts
Use Vue's Plugin type to keep install signatures and options type-safe.
import type { App, Plugin } from 'vue'
interface MyOptions {
apiKey: string
}
const myPlugin: Plugin<[MyOptions]> = {
install(app: App, options: MyOptions) {
app.provide(apiKeyKey, options.apiKey)
}
}Use Symbol Injection Keys in Plugins
String keys can collide ('http', 'config', 'i18n'). Use symbol keys with InjectionKey<T> so injections are unique and typed.
BAD:
export default {
install(app) {
app.provide('http', axios)
app.provide('config', appConfig)
}
}GOOD:
import type { InjectionKey } from 'vue'
import type { AxiosInstance } from 'axios'
interface AppConfig {
apiUrl: string
timeout: number
}
export const httpKey: InjectionKey<AxiosInstance> = Symbol('http')
export const configKey: InjectionKey<AppConfig> = Symbol('appConfig')
export default {
install(app) {
app.provide(httpKey, axios)
app.provide(configKey, { apiUrl: '/api', timeout: 5000 })
}
}Provide Required Injection Helpers
Wrap required injections in composables that throw clear setup errors.
import { inject } from 'vue'
import { authKey, type AuthService } from '@/injection-keys'
export function useAuth(): AuthService {
const auth = inject(authKey)
if (!auth) {
throw new Error('Auth plugin not installed. Did you forget app.use(authPlugin)?')
}
return auth
}Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch)
Impact: MEDIUM - Choose the right reactive primitive first, derive with computed, and use watchers only for side effects.
This reference covers the core reactivity decisions for local state, external data, derived values, and effects.
Task List
- Declare reactive state correctly
- Always use
shallowRef()instead ofref()for primitive values - Choose the correct reactive declaration method for objects/arrays/map/set
- Follow best practices for
reactive - Avoid destructuring from
reactive()directly - Watch correctly for
reactive - Follow best practices for
computed - Prefer
computedover watcher-assigned derived refs - Keep filtered/sorted derivations out of templates
- Use
computedfor reusable class/style logic - Keep computed getters pure (no side effects) and put side effects in watchers
- Follow best practices for watchers
- Use
immediate: trueinstead of duplicate initial calls - Clean up async effects for watchers
Declare reactive state correctly
Always use shallowRef() instead of ref() for primitive values (string, number, boolean, null, etc.) for better performance.
Incorrect:
import { ref } from 'vue'
const count = ref(0)Correct:
import { shallowRef } from 'vue'
const count = shallowRef(0)Choose the correct reactive declaration method for objects/arrays/map/set
Use ref() when you often replace the entire value (state.value = newObj) and still want deep reactivity inside it, usually used for:
- Frequently reassigned state (replace fetched object/list, reset to defaults, switch presets).
- Composable return values where updates happen mostly via
.valuereassignment.
Use reactive() when you mainly mutate properties and full replacement is uncommon, usually used for:
- “Single state object” patterns (stores/forms):
state.count++,state.items.push(...),state.user.name = .... - Situations where you want to avoid
.valueand update nested fields in place.
import { reactive } from 'vue'
const state = reactive({
count: 0,
user: { name: 'Alice', age: 30 }
})
state.count++ // ✅ reactive
state.user.age = 31 // ✅ reactive
// ❌ avoid replacing the reactive object reference:
// state = reactive({ count: 1 })Use shallowRef() when the value is opaque / should not be proxied (class instances, external library objects, very large nested data) and you only want updates to trigger when you replace state.value (no deep tracking), usually used for:
- Storing external instances/handles (SDK clients, class instances) without Vue proxying internals.
- Large data where you update by replacing the root reference (immutable-style updates).
import { shallowRef } from 'vue'
const user = shallowRef({ name: 'Alice', age: 30 })
user.value.age = 31 // ❌ not reactive
user.value = { name: 'Bob', age: 25 } // ✅ triggers updateUse shallowReactive() when you want only top-level properties reactive; nested objects remain raw, usually used for:
- Container objects where only top-level keys change and nested payloads should stay unmanaged/unproxied.
- Mixed structures where Vue tracks the wrapper object, but not deeply nested or foreign objects.
import { shallowReactive } from 'vue'
const state = shallowReactive({
count: 0,
user: { name: 'Alice', age: 30 }
})
state.count++ // ✅ reactive
state.user.age = 31 // ❌ not reactiveBest practices for reactive
Avoid destructuring from reactive() directly
BAD:
import { reactive } from 'vue'
const state = reactive({ count: 0 })
const { count } = state // ❌ disconnected from reactivityWatch correctly for reactive
BAD:
passing a non-getter value into watch()
import { reactive, watch } from 'vue'
const state = reactive({ count: 0 })
// ❌ watch expects a getter, ref, reactive object, or array of these
watch(state.count, () => { /* ... */ })GOOD:
preserve reactivity with toRefs() and use a getter for watch()
import { reactive, toRefs, watch } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state) // ✅ count is a ref
watch(count, () => { /* ... */ }) // ✅
watch(() => state.count, () => { /* ... */ }) // ✅Best practices for computed
Prefer computed over watcher-assigned derived refs
BAD:
import { ref, watchEffect } from 'vue'
const items = ref([{ price: 10 }, { price: 20 }])
const total = ref(0)
watchEffect(() => {
total.value = items.value.reduce((sum, item) => sum + item.price, 0)
})GOOD:
import { ref, computed } from 'vue'
const items = ref([{ price: 10 }, { price: 20 }])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
)Keep filtered/sorted derivations out of templates
BAD:
<template>
<li v-for="item in items.filter(item => item.active)" :key="item.id">
{{ item.name }}
</li>
<li v-for="item in getSortedItems()" :key="item.id">
{{ item.name }}
</li>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: 'B', active: true },
{ id: 2, name: 'A', active: false }
])
function getSortedItems() {
return [...items.value].sort((a, b) => a.name.localeCompare(b.name))
}
</script>GOOD:
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, name: 'B', active: true },
{ id: 2, name: 'A', active: false }
])
const visibleItems = computed(() =>
items.value
.filter(item => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
)
</script>
<template>
<li v-for="item in visibleItems" :key="item.id">
{{ item.name }}
</li>
</template>Use computed for reusable class/style logic
BAD:
<template>
<button :class="{ btn: true, 'btn-primary': type === 'primary' && !disabled, 'btn-disabled': disabled }">
{{ label }}
</button>
</template>GOOD:
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: { type: String, default: 'primary' },
disabled: Boolean,
label: String
})
const buttonClasses = computed(() => ({
btn: true,
[`btn-${props.type}`]: !props.disabled,
'btn-disabled': props.disabled
}))
</script>
<template>
<button :class="buttonClasses">
{{ label }}
</button>
</template>Keep computed getters pure (no side effects) and put side effects in watchers instead
A computed getter should only derive a value. No mutation, no API calls, no storage writes, no event emits. (Reference)
BAD:
side effects inside computed
const count = ref(0)
const doubled = computed(() => {
// ❌ side effect
if (count.value > 10) console.warn('Too big!')
return count.value * 2
})GOOD:
pure computed + watch() for side effects
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, (value) => {
if (value > 10) console.warn('Too big!')
})Best practices for watchers
Use immediate: true instead of duplicate initial calls
BAD:
import { ref, watch, onMounted } from 'vue'
const userId = ref(1)
function loadUser(id) {
// ...
}
onMounted(() => loadUser(userId.value))
watch(userId, (id) => loadUser(id))GOOD:
import { ref, watch } from 'vue'
const userId = ref(1)
watch(
userId,
(id) => loadUser(id),
{ immediate: true }
)Clean up async effects for watchers
When reacting to rapid changes (search boxes, filters), cancel the previous request.
GOOD:
const query = ref('')
const results = ref<string[]>([])
watch(query, async (q, _prev, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
})
results.value = await res.json()
})Render Function Patterns and Performance
Impact: MEDIUM - Render functions are powerful but opt out of template compiler optimizations. Use them intentionally and apply the key patterns below to keep output correct and performant.
Task List
- Prefer templates; use render functions only when templates cannot express the logic
- Always add stable keys when rendering lists with
h()/JSX - Use
withModifiers/withKeysfor event modifiers - Implement
v-modelviamodelValue+onUpdate:modelValue - Apply custom directives with
withDirectives - Use functional components for stateless presentational UI
Prefer templates over render functions
BAD:
<script setup>
import { h, ref } from 'vue'
const count = ref(0)
const render = () => h('div', `Count: ${count.value}`)
</script>GOOD:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<div>Count: {{ count }}</div>
</template>Always add keys for list rendering
BAD:
import { h, ref } from 'vue'
export default {
setup() {
const items = ref([{ id: 1, name: 'Apple' }])
return () => h('ul',
items.value.map(item => h('li', item.name))
)
}
}GOOD:
import { h, ref } from 'vue'
export default {
setup() {
const items = ref([{ id: 1, name: 'Apple' }])
return () => h('ul',
items.value.map(item => h('li', { key: item.id }, item.name))
)
}
}Use withModifiers / withKeys for event modifiers
BAD:
import { h } from 'vue'
export default {
setup() {
const handleClick = (e) => {
e.stopPropagation()
e.preventDefault()
}
return () => h('button', { onClick: handleClick }, 'Click')
}
}GOOD:
import { h, withModifiers, withKeys } from 'vue'
export default {
setup() {
const handleClick = () => {}
const handleEnter = () => {}
return () => h('div', [
h('button', {
onClick: withModifiers(handleClick, ['stop', 'prevent'])
}, 'Click'),
h('input', {
onKeyup: withKeys(handleEnter, ['enter'])
})
])
}
}Implement v-model explicitly
BAD:
import { h, ref } from 'vue'
import CustomInput from './CustomInput.vue'
export default {
setup() {
const text = ref('')
return () => h(CustomInput, { modelValue: text.value })
}
}GOOD:
import { h, ref } from 'vue'
import CustomInput from './CustomInput.vue'
export default {
setup() {
const text = ref('')
return () => h(CustomInput, {
modelValue: text.value,
'onUpdate:modelValue': (value) => { text.value = value }
})
}
}Use withDirectives for custom directives
BAD:
import { h } from 'vue'
const vFocus = { mounted: (el) => el.focus() }
export default {
setup() {
return () => h('input', { 'v-focus': true })
}
}GOOD:
import { h, withDirectives } from 'vue'
const vFocus = { mounted: (el) => el.focus() }
export default {
setup() {
return () => withDirectives(h('input'), [[vFocus]])
}
}Prefer functional components for stateless UI
BAD:
import { h } from 'vue'
export default {
setup() {
return () => h('span', { class: 'badge' }, 'New')
}
}GOOD:
import { h } from 'vue'
function Badge(props, { slots }) {
return h('span', { class: 'badge' }, slots.default?.())
}
Badge.props = ['variant']
export default BadgeSingle-File Component Structure, Styling, and Template Patterns
Impact: MEDIUM - Using SFCs with consistent structure and performant styling keeps components easier to maintain and avoids unnecessary render overhead.
Task List
- Use
.vueSFCs instead of separate.js/.tsand.cssfiles for components - Colocate template, script, and styles in the same SFC by default
- Use PascalCase for component names in templates and filenames
- Prefer component-scoped styles
- Prefer class selectors (not element selectors) in scoped CSS for performance
- Access DOM / component refs with
useTemplateRef()in Vue 3.5+ - Use camelCase keys in
:stylebindings for consistency and IDE support - Use
v-forandv-ifcorrectly - Never use
v-htmlwith untrusted/user-provided content - Choose
v-ifvsv-showbased on toggle frequency and initial render cost
Colocate template, script, and styles
BAD:
components/
├── UserCard.vue
├── UserCard.js
└── UserCard.cssGOOD:
<!-- components/UserCard.vue -->
<script setup>
import { computed } from 'vue'
const props = defineProps({
user: { type: Object, required: true }
})
const displayName = computed(() =>
`${props.user.firstName} ${props.user.lastName}`
)
</script>
<template>
<div class="user-card">
<h3 class="name">{{ displayName }}</h3>
</div>
</template>
<style scoped>
.user-card {
padding: 1rem;
}
.name {
margin: 0;
}
</style>Use PascalCase for component names
BAD:
<script setup>
import userProfile from './user-profile.vue'
</script>
<template>
<user-profile :user="currentUser" />
</template>GOOD:
<script setup>
import UserProfile from './UserProfile.vue'
</script>
<template>
<UserProfile :user="currentUser" />
</template>Best practices for <style> block in SFCs
Prefer component-scoped styles
- Use
<style scoped>for styles that belong to a component. - Keep global CSS in a dedicated file (e.g.
src/assets/main.css) for resets, typography, tokens, etc. - Use
:deep()sparingly (edge cases only).
BAD:
<style>
/* ❌ leaks everywhere */
button { border-radius: 999px; }
</style>GOOD:
<style scoped>
.button { border-radius: 999px; }
</style>GOOD:
/* src/assets/main.css */
/* ✅ resets, tokens, typography, app-wide rules */
:root { --radius: 999px; }Use class selectors in scoped CSS
BAD:
<template>
<article>
<h1>{{ title }}</h1>
<p>{{ subtitle }}</p>
</article>
</template>
<style scoped>
article { max-width: 800px; }
h1 { font-size: 2rem; }
p { line-height: 1.6; }
</style>GOOD:
<template>
<article class="article">
<h1 class="article-title">{{ title }}</h1>
<p class="article-subtitle">{{ subtitle }}</p>
</article>
</template>
<style scoped>
.article { max-width: 800px; }
.article-title { font-size: 2rem; }
.article-subtitle { line-height: 1.6; }
</style>Access DOM / component refs with useTemplateRef()
For Vue 3.5+: use useTemplateRef() to access template refs.
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
const inputRef = useTemplateRef<HTMLInputElement>('input')
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<input ref="input" />
</template>Use camelCase in :style bindings
BAD:
<template>
<div :style="{ 'font-size': fontSize + 'px', 'background-color': bg }">
Content
</div>
</template>GOOD:
<template>
<div :style="{ fontSize: fontSize + 'px', backgroundColor: bg }">
Content
</div>
</template>Use v-for and v-if correctly
Always provide a stable :key
- Prefer primitive keys (
string | number). - Avoid using objects as keys.
GOOD:
<li v-for="item in items" :key="item.id">
<input v-model="item.text" />
</li>Avoid v-if and v-for on the same element
It leads to unclear intent and unnecessary work. (Reference)
To filter items BAD:
<li v-for="user in users" v-if="user.active" :key="user.id">
{{ user.name }}
</li>GOOD:
<script setup lang="ts">
import { computed } from 'vue'
const activeUsers = computed(() => users.value.filter(u => u.active))
</script>
<template>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</template>To conditionally show/hide the entire list GOOD:
<ul v-if="shouldShowUsers">
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>Never render untrusted HTML with v-html
BAD:
<template>
<!-- DANGEROUS: untrusted input can inject scripts -->
<article v-html="userProvidedContent"></article>
</template>GOOD:
<script setup>
import { computed } from 'vue'
import DOMPurify from 'dompurify'
const props = defineProps<{
trustedHtml?: string
plainText: string
}>()
const safeHtml = computed(() => DOMPurify.sanitize(props.trustedHtml ?? ''))
</script>
<template>
<!-- Preferred: escaped interpolation -->
<p>{{ props.plainText }}</p>
<!-- Only for trusted/sanitized HTML -->
<article v-html="safeHtml"></article>
</template>Choose v-if vs v-show by toggle behavior
BAD:
<template>
<!-- Frequent toggles with v-if cause repeated mount/unmount -->
<ComplexPanel v-if="isPanelOpen" />
<!-- Rarely shown content with v-show pays initial render cost -->
<AdminPanel v-show="isAdmin" />
</template>GOOD:
<template>
<!-- Frequent toggles: keep in DOM, toggle display -->
<ComplexPanel v-show="isPanelOpen" />
<!-- Rare condition: lazy render only when true -->
<AdminPanel v-if="isAdmin" />
</template>State Management Strategy
Impact: HIGH - Use the lightest state solution that fits your app architecture. SPA-only apps can use lightweight global composables, while SSR/Nuxt apps should default to Pinia for request-safe isolation and predictable tooling.
Task List
- Keep state local first, then promote to shared/global only when needed
- Use singleton composables only in non-SSR applications
- Expose global state as readonly and mutate through explicit actions
- Prefer Pinia for SSR/Nuxt, large apps, and advanced debugging/plugin needs
- Avoid exporting mutable module-level reactive state directly
Choose the Lightest Store Approach
- Feature composable: Default for reusable logic with local/feature-level state.
- Singleton composable or VueUse `createGlobalState`: Small non-SSR apps needing shared app state.
- Pinia: SSR/Nuxt apps, medium-to-large apps, and cases requiring DevTools, plugins, or action tracing.
Avoid Exporting Mutable Module State
BAD:
// store/cart.ts
import { reactive } from 'vue'
export const cart = reactive({
items: [] as Array<{ id: string; qty: number }>
})GOOD:
// composables/useCartStore.ts
import { reactive, readonly } from 'vue'
let _store: ReturnType<typeof createCartStore> | null = null
function createCartStore() {
const state = reactive({
items: [] as Array<{ id: string; qty: number }>
})
function addItem(id: string, qty = 1) {
const existing = state.items.find((item) => item.id === id)
if (existing) {
existing.qty += qty
return
}
state.items.push({ id, qty })
}
return {
state: readonly(state),
addItem
}
}
export function useCartStore() {
if (!_store) _store = createCartStore()
return _store
}Do Not Use Runtime Singletons in SSR
Module singletons live for the runtime lifetime. In SSR this can leak state between requests.
BAD:
// shared singleton reused across requests
const cartStore = useCartStore()
export function useServerCart() {
return cartStore
}GOOD:
pinia dependency required.// stores/cart.ts
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as Array<{ id: string; qty: number }>
}),
actions: {
addItem(id: string, qty = 1) {
const existing = this.items.find((item) => item.id === id)
if (existing) {
existing.qty += qty
return
}
this.items.push({ id, qty })
}
}
})Use createGlobalState for Small SPA Global State
@vueuse/core dependency required.If the app is non-SSR and already uses VueUse, createGlobalState removes singleton boilerplate.
import { createGlobalState } from '@vueuse/core'
import { computed, ref } from 'vue'
export const useAuthState = createGlobalState(() => {
const token = ref<string | null>(null)
const isAuthenticated = computed(() => token.value !== null)
function setToken(next: string | null) {
token.value = next
}
return {
token,
isAuthenticated,
setToken
}
})Avoid Expensive Operations in Updated Hook
Impact: MEDIUM - The updated hook runs after every reactive state change that causes a re-render. Placing expensive operations, API calls, or state mutations here can cause severe performance degradation, infinite loops, and dropped frames below the optimal 60fps threshold.
Use updated/onUpdated sparingly for post-DOM-update operations that cannot be handled by watchers or computed properties. For most reactive data handling, prefer watchers (watch/watchEffect) which provide more control over what triggers the callback.
Task List
- Never perform API calls in updated hook
- Never mutate reactive state inside updated (causes infinite loops)
- Use conditional checks to verify updates are relevant before acting
- Prefer
watchorwatchEffectfor reacting to specific data changes - Use throttling/debouncing if updated operations are expensive
- Reserve updated for low-level DOM synchronization tasks
BAD:
// BAD: API call in updated - fires on every re-render
export default {
data() {
return { items: [], lastUpdate: null }
},
updated() {
// This runs after every single state change!
fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(this.items)
})
}
}// BAD: State mutation in updated - infinite loop
export default {
data() {
return { renderCount: 0 }
},
updated() {
// This causes another update, which triggers updated again!
this.renderCount++ // Infinite loop
}
}// BAD: Heavy computation on every update
export default {
updated() {
// Expensive operation runs on every keystroke, every state change
this.processedData = this.heavyComputation(this.rawData)
this.analytics = this.calculateMetrics(this.allData)
}
}GOOD:
import debounce from 'lodash-es/debounce'
// GOOD: Use watcher for specific data changes
export default {
data() {
return { items: [] }
},
watch: {
// Only fires when items actually changes
items: {
handler(newItems) {
this.syncToServer(newItems)
},
deep: true
}
},
methods: {
syncToServer: debounce(function(items) {
fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(items)
})
}, 500)
}
}<!-- GOOD: Composition API with targeted watchers -->
<script setup>
import { ref, watch, onUpdated } from 'vue'
import { useDebounceFn } from '@vueuse/core'
const items = ref([])
const scrollContainer = ref(null)
// Watch specific data - not all updates
watch(items, (newItems) => {
syncToServer(newItems)
}, { deep: true })
const syncToServer = useDebounceFn((items) => {
fetch('/api/sync', { method: 'POST', body: JSON.stringify(items) })
}, 500)
// Only use onUpdated for DOM synchronization
onUpdated(() => {
// Scroll to bottom only if content changed height
if (scrollContainer.value) {
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight
}
})
</script>// GOOD: Conditional check in updated hook
export default {
data() {
return {
content: '',
lastSyncedContent: ''
}
},
updated() {
// Only act if specific condition is met
if (this.content !== this.lastSyncedContent) {
this.syncContent()
this.lastSyncedContent = this.content
}
},
methods: {
syncContent: debounce(function() {
// Sync logic
}, 300)
}
}Valid Use Cases for Updated Hook
// GOOD: Low-level DOM synchronization
export default {
updated() {
// Sync third-party library with Vue's DOM
this.thirdPartyWidget.refresh()
// Update scroll position after content change
this.$nextTick(() => {
this.maintainScrollPosition()
})
}
}Prefer Computed Properties for Derived Data
// BAD: Calculating derived data in updated
export default {
data() {
return { numbers: [1, 2, 3, 4, 5] }
},
updated() {
this.sum = this.numbers.reduce((a, b) => a + b, 0) // Causes another update!
}
}
// GOOD: Use computed property instead
export default {
data() {
return { numbers: [1, 2, 3, 4, 5] }
},
computed: {
sum() {
return this.numbers.reduce((a, b) => a + b, 0)
}
}
}Related skills
Forks & variants (4)
Vue Best Practices has 4 known copies in the catalog totaling 32k installs. They canonicalize to this original listing.
How it compares
Pick vue-best-practices for opinionated Vue 3 Composition API standards; use framework-agnostic frontend skills for non-Vue stacks.
FAQ
Does vue-best-practices require Composition API?
vue-best-practices strongly recommends Composition API with script setup and TypeScript for Vue 3 work. Options API is used only when a project explicitly requires it.
What Vue tooling does vue-best-practices cover?
vue-best-practices covers Vue 3, SSR, Volar, vue-tsc, Vue Router, Pinia, and Vite-with-Vue projects. The skill version is 18.0.0 from github.com/vuejs-ai.
Is Vue Best Practices safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.