feat: Complete fleet — 94 skills across 10+ domains

Pulled ALL skills from 15 source repositories:
- anthropics/skills: 16 (docs, design, MCP, testing)
- obra/superpowers: 14 (TDD, debugging, agents, planning)
- coreyhaines31/marketingskills: 25 (marketing, CRO, SEO, growth)
- better-auth/skills: 5 (auth patterns)
- vercel-labs/agent-skills: 5 (React, design, Vercel)
- antfu/skills: 16 (Vue, Vite, Vitest, pnpm, Turborepo)
- Plus 13 individual skills from various repos

Mosaic Stack is not limited to coding — the Orchestrator and
subagents serve coding, business, design, marketing, writing,
logistics, analysis, and more.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-16 16:27:42 -06:00
parent 861b28b965
commit f5792c40be
1262 changed files with 212048 additions and 61 deletions

View File

@@ -0,0 +1,258 @@
---
title: Use Class-based Animations for Non-Enter/Leave Effects
impact: LOW
impactDescription: Class-based animations are simpler and more performant for elements that remain in the DOM
type: best-practice
tags: [vue3, animation, css, class-binding, state]
---
# 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 Checklist
- [ ] 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 `setTimeout` to 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
```vue
<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
```vue
<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
```vue
<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
```vue
<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:
```vue
<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
```javascript
// 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
}
}
```
```vue
<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>
```
## Reference
- [Vue.js Animation Techniques - Class-based Animations](https://vuejs.org/guide/extras/animation.html#class-based-animations)
- [CSS Animations MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Animations)

View File

@@ -0,0 +1,160 @@
---
title: Use Key Attribute to Force Re-render Animations
impact: MEDIUM
impactDescription: Without key attributes, Vue reuses DOM elements and animation libraries like AutoAnimate cannot detect changes to animate
type: gotcha
tags: [vue3, animation, key, autoanimate, rerender, dom]
---
# Use Key Attribute to Force Re-render Animations
**Impact: MEDIUM** - Vue optimizes performance by reusing DOM elements when possible. However, this optimization can prevent animation libraries (like AutoAnimate) from detecting changes, because the element is updated in place rather than re-created. Adding a `:key` attribute forces Vue to treat changed elements as new, triggering proper animations.
## Task Checklist
- [ ] Add `:key` to elements that should animate when their content changes
- [ ] Use unique, changing values for keys (not indices)
- [ ] For route transitions, add `:key="$route.fullPath"` to `<router-view>`
- [ ] Apply `v-auto-animate` to the parent element of keyed children
**Problematic Code:**
```vue
<template>
<!-- BAD: Text changes but no animation occurs -->
<div v-auto-animate>
<p>{{ message }}</p> <!-- No key - element is reused -->
</div>
<!-- BAD: Image source changes but no animation -->
<div v-auto-animate>
<img :src="imageUrl" /> <!-- No key - element is reused -->
</div>
<!-- BAD: Route changes don't animate -->
<router-view v-auto-animate /> <!-- No key -->
</template>
<script setup>
import { ref } from 'vue'
const message = ref('Hello')
const imageUrl = ref('/images/photo1.jpg')
// Changing these won't trigger animations because
// Vue updates the existing elements rather than replacing them
</script>
```
**Correct Code:**
```vue
<template>
<!-- GOOD: Key forces re-render, triggering animation -->
<div v-auto-animate>
<p :key="message">{{ message }}</p>
</div>
<!-- GOOD: Image animates when source changes -->
<div v-auto-animate>
<img :key="imageUrl" :src="imageUrl" />
</div>
<!-- GOOD: Route changes animate properly -->
<router-view :key="$route.fullPath" v-auto-animate />
</template>
<script setup>
import { ref } from 'vue'
const message = ref('Hello')
const imageUrl = ref('/images/photo1.jpg')
// Now changing these will trigger animations
function updateMessage() {
message.value = 'World' // Triggers enter animation for new <p>
}
</script>
```
## Why This Works
When Vue sees a `:key` change:
1. It considers the old element and new element as different
2. The old element is removed (triggering leave animation)
3. A new element is created (triggering enter animation)
Without `:key`:
1. Vue sees the same element type in the same position
2. It updates the element's properties in place
3. No DOM addition/removal occurs, so no animation triggers
## Common Use Cases
### Animating Text Content Changes
```vue
<template>
<div v-auto-animate>
<h1 :key="title">{{ title }}</h1>
<p :key="description">{{ description }}</p>
</div>
</template>
```
### Animating Dynamic Components
```vue
<template>
<div v-auto-animate>
<component :is="currentComponent" :key="currentComponent" />
</div>
</template>
```
### Animating Route Transitions
```vue
<template>
<router-view v-slot="{ Component, route }">
<div v-auto-animate>
<component :is="Component" :key="route.fullPath" />
</div>
</router-view>
</template>
```
## With Vue's Built-in Transition
The same principle applies to Vue's `<Transition>` component:
```vue
<template>
<!-- GOOD: Key triggers transition on content change -->
<Transition name="fade" mode="out-in">
<p :key="message">{{ message }}</p>
</Transition>
<!-- GOOD: Different keys for conditional content -->
<Transition name="fade" mode="out-in">
<div v-if="isLoading" key="loading">Loading...</div>
<div v-else key="content">{{ content }}</div>
</Transition>
</template>
```
## Caution: Performance Implications
Using `:key` forces full component re-creation. For frequently changing data:
- The entire component tree under the keyed element is destroyed and recreated
- Any component state is lost
- Consider whether the animation is worth the performance cost
```vue
<!-- Be cautious with complex components -->
<ComplexDashboard :key="refreshTrigger" />
<!-- This destroys and recreates the entire dashboard! -->
```
## Reference
- [Vue.js Animation Techniques](https://vuejs.org/guide/extras/animation.html)
- [AutoAnimate with Vue](https://auto-animate.formkit.com/#usage-vue)
- [Vue.js v-for with key](https://vuejs.org/guide/essentials/list.html#maintaining-state-with-key)

View File

@@ -0,0 +1,295 @@
---
title: State-driven Animations with CSS Transitions and Style Bindings
impact: LOW
impactDescription: Combining Vue's reactive style bindings with CSS transitions creates smooth, interactive animations
type: best-practice
tags: [vue3, animation, css, transition, style-binding, state, interactive]
---
# 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 Checklist
- [ ] Use `:style` binding for dynamic properties that change frequently
- [ ] Add CSS `transition` property to smoothly animate between values
- [ ] Consider using `transform` and `opacity` for GPU-accelerated animations
- [ ] For complex value interpolation, use watchers with animation libraries
## Basic Pattern
```vue
<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
```vue
<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
```vue
<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
```vue
<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
```vue
<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:
```vue
<template>
<div>
<input v-model.number="targetNumber" type="number" />
<p class="counter">{{ displayNumber.toFixed(0) }}</p>
</div>
</template>
<script setup>
import { 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
```vue
<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>
```
## Reference
- [Vue.js Animation Techniques - State-driven Animations](https://vuejs.org/guide/extras/animation.html#state-driven-animations)
- [CSS Transitions MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Transitions)

View File

@@ -0,0 +1,241 @@
---
title: TransitionGroup Performance with Large Lists and CSS Frameworks
impact: MEDIUM
impactDescription: TransitionGroup can cause noticeable DOM update lag when animating list changes, especially with CSS frameworks
type: gotcha
tags: [vue3, transition-group, animation, performance, list, css-framework]
---
# TransitionGroup Performance with Large Lists and CSS Frameworks
**Impact: MEDIUM** - Vue's `<TransitionGroup>` can experience significant DOM update lag when animating list changes, particularly when:
- Using CSS frameworks (Tailwind, Bootstrap, etc.)
- Performing array operations like `slice()` that change multiple items
- Working with larger lists
Without TransitionGroup, DOM updates occur instantly. With it, there can be noticeable delay before the UI reflects changes.
## Task Checklist
- [ ] For frequently updated lists, consider if transition animations are necessary
- [ ] Use CSS `content-visibility: auto` for long lists to reduce render cost
- [ ] Minimize CSS framework classes on list items during transitions
- [ ] Consider virtual scrolling for very large animated lists
- [ ] Profile with Vue DevTools to identify transition bottlenecks
**Problematic Pattern:**
```vue
<template>
<!-- Potentially slow with large lists or complex CSS -->
<TransitionGroup name="list" tag="ul">
<li
v-for="item in items"
:key="item.id"
class="p-4 m-2 rounded-lg shadow-md bg-gradient-to-r from-blue-500 to-purple-600
hover:shadow-lg transition-all duration-300 ease-in-out transform hover:scale-105
border border-gray-200 flex items-center justify-between"
>
{{ item.name }}
</li>
</TransitionGroup>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([/* many items */])
// Operations like slice can cause visible lag
function removeItems() {
items.value = items.value.slice(5) // May lag with TransitionGroup
}
</script>
<style>
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
</style>
```
**Optimized Approach:**
```vue
<template>
<!-- Simpler classes, shorter transitions -->
<TransitionGroup name="list" tag="ul" class="relative">
<li
v-for="item in items"
:key="item.id"
class="list-item"
>
{{ item.name }}
</li>
</TransitionGroup>
</template>
<script setup>
import { ref, computed } from 'vue'
const items = ref([/* items */])
// For large batch operations, consider disabling animations temporarily
const isAnimating = ref(true)
</script>
<style>
/* Keep transition CSS simple and specific */
.list-item {
/* Minimal styles during animation */
padding: 1rem;
}
.list-move {
transition: transform 0.3s ease; /* Shorter duration */
}
.list-enter-active,
.list-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(-20px);
}
/* Use will-change sparingly */
.list-enter-active {
will-change: opacity, transform;
}
/* Absolute positioning for leaving elements prevents layout thrashing */
.list-leave-active {
position: absolute;
width: 100%;
}
</style>
```
## Performance Optimization Strategies
### 1. Skip Animations for Bulk Operations
```vue
<template>
<TransitionGroup v-if="animationsEnabled" name="list" tag="ul">
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</TransitionGroup>
<!-- Instant update without animations -->
<ul v-else>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script setup>
import { ref, nextTick } from 'vue'
const animationsEnabled = ref(true)
async function bulkUpdate(newItems) {
// Disable animations for bulk operations
animationsEnabled.value = false
items.value = newItems
await nextTick()
animationsEnabled.value = true
}
</script>
```
### 2. Virtual Scrolling for Large Lists
```vue
<template>
<!-- Use a virtual list library for large datasets -->
<RecycleScroller
:items="items"
:item-size="50"
key-field="id"
v-slot="{ item }"
>
<div class="list-item">{{ item.name }}</div>
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
</script>
```
### 3. Reduce CSS Complexity During Transitions
```vue
<style>
/* Move complex styles to a stable wrapper */
.list-item-wrapper {
@apply p-4 m-2 rounded-lg shadow-md bg-gradient-to-r from-blue-500 to-purple-600;
}
/* Keep animated element styles minimal */
.list-item {
/* Only essential layout styles */
}
.list-move,
.list-enter-active,
.list-leave-active {
/* Only animate transform/opacity - GPU accelerated */
transition: transform 0.3s ease, opacity 0.3s ease;
}
</style>
```
### 4. Use CSS content-visibility
```css
/* For very long lists, defer rendering of off-screen items */
.list-item {
content-visibility: auto;
contain-intrinsic-size: 0 50px; /* Estimated height */
}
```
## When to Avoid TransitionGroup
Consider alternatives when:
- List updates are frequent (real-time data)
- List contains 100+ items
- Items have complex CSS or nested components
- Performance is critical (mobile, low-end devices)
```vue
<!-- Simple alternative: CSS-only animations on individual items -->
<ul>
<li
v-for="item in items"
:key="item.id"
class="animate-in"
>
{{ item.name }}
</li>
</ul>
<style>
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.3s ease forwards;
}
</style>
```
## Reference
- [Vue.js TransitionGroup](https://vuejs.org/guide/built-ins/transition-group.html)
- [GitHub Issue: transition-group DOM update lag](https://github.com/vuejs/vue/issues/5845)
- [Vue Virtual Scroller](https://github.com/Akryum/vue-virtual-scroller)

View File

@@ -0,0 +1,142 @@
# Async Component Lazy Hydration Strategies (Vue 3.5+)
## Rule
In Vue 3.5+, use hydration strategies with async components to control when SSR-rendered components become interactive. Import hydration strategies individually for tree-shaking.
## Why This Matters
In SSR applications, hydrating all components immediately can block the main thread and delay interactivity. Lazy hydration allows non-critical components to become interactive only when needed, improving Time to Interactive (TTI) metrics.
## Available Hydration Strategies
### hydrateOnIdle
Hydrates when the browser is idle using `requestIdleCallback`:
```vue
<script setup>
import { defineAsyncComponent, hydrateOnIdle } from 'vue'
// Good for non-critical, below-the-fold content
const AsyncFooter = defineAsyncComponent({
loader: () => import('./Footer.vue'),
hydrate: hydrateOnIdle()
})
// With max timeout (in case idle never occurs)
const AsyncSidebar = defineAsyncComponent({
loader: () => import('./Sidebar.vue'),
hydrate: hydrateOnIdle(5000) // Max 5 seconds
})
</script>
```
### hydrateOnVisible
Hydrates when element enters the viewport via `IntersectionObserver`:
```vue
<script setup>
import { defineAsyncComponent, hydrateOnVisible } from 'vue'
// Good for content below the fold
const AsyncComments = defineAsyncComponent({
loader: () => import('./Comments.vue'),
hydrate: hydrateOnVisible()
})
// With root margin for earlier hydration
const AsyncRelatedPosts = defineAsyncComponent({
loader: () => import('./RelatedPosts.vue'),
hydrate: hydrateOnVisible({ rootMargin: '100px' })
})
</script>
```
### hydrateOnMediaQuery
Hydrates when a media query matches:
```vue
<script setup>
import { defineAsyncComponent, hydrateOnMediaQuery } from 'vue'
// Only hydrate on mobile devices
const AsyncMobileMenu = defineAsyncComponent({
loader: () => import('./MobileMenu.vue'),
hydrate: hydrateOnMediaQuery('(max-width: 768px)')
})
// Only hydrate on desktop
const AsyncDesktopSidebar = defineAsyncComponent({
loader: () => import('./DesktopSidebar.vue'),
hydrate: hydrateOnMediaQuery('(min-width: 1024px)')
})
</script>
```
### hydrateOnInteraction
Hydrates when user interacts with the component:
```vue
<script setup>
import { defineAsyncComponent, hydrateOnInteraction } from 'vue'
// Hydrate on click
const AsyncDropdown = defineAsyncComponent({
loader: () => import('./Dropdown.vue'),
hydrate: hydrateOnInteraction('click')
})
// Hydrate on multiple events
const AsyncTooltip = defineAsyncComponent({
loader: () => import('./Tooltip.vue'),
hydrate: hydrateOnInteraction(['mouseover', 'focus'])
})
</script>
```
**Note**: The triggering event is replayed after hydration completes, so user interaction is not lost.
## Custom Hydration Strategy
```typescript
import { defineAsyncComponent, type HydrationStrategy } from 'vue'
const hydrateAfterAnimation: HydrationStrategy = (hydrate, forEachElement) => {
// Wait for page load animation to complete
const timeout = setTimeout(hydrate, 1000)
return () => clearTimeout(timeout) // Cleanup
}
const AsyncWidget = defineAsyncComponent({
loader: () => import('./Widget.vue'),
hydrate: hydrateAfterAnimation
})
```
## Key Points
1. Hydration strategies only apply to SSR - they have no effect in client-only apps
2. Import strategies individually: `import { hydrateOnIdle } from 'vue'`
3. `hydrateOnInteraction` replays the triggering event after hydration
4. Use `hydrateOnVisible` for below-the-fold content
5. Use `hydrateOnIdle` for non-critical components
6. Use `hydrateOnMediaQuery` for device-specific components
## Strategy Selection Guide
| Component Type | Recommended Strategy |
|----------------|---------------------|
| Footer, related content | `hydrateOnIdle` |
| Below-the-fold sections | `hydrateOnVisible` |
| Interactive widgets | `hydrateOnInteraction` |
| Mobile-only components | `hydrateOnMediaQuery` |
| Critical above-the-fold | No strategy (immediate) |
## References
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)

View File

@@ -0,0 +1,76 @@
# Async Component Loading Delay for Flicker Prevention
## Rule
Use the `delay` option (default 200ms) when configuring async components with a `loadingComponent`. This prevents UI flicker on fast networks where the component loads quickly.
## Why This Matters
Without a delay, the loading component briefly appears and immediately disappears when the async component loads quickly. This creates a jarring "flash" effect that degrades user experience. The 200ms default is chosen because loads faster than this are perceived as instant.
## Bad Code
```vue
<script setup>
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
delay: 0 // Loading spinner flashes immediately, causing flicker
})
</script>
```
## Good Code
```vue
<script setup>
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
// delay: 200 is the default, but you can adjust based on your UX needs
})
</script>
```
```vue
<script setup>
import { defineAsyncComponent } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import ErrorDisplay from './ErrorDisplay.vue'
// For slower expected loads, consider a shorter delay
const AsyncHeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 100, // Show loading sooner for components known to be heavy
timeout: 30000 // Longer timeout for complex components
})
</script>
```
## Choosing the Right Delay
| Scenario | Recommended Delay |
|----------|-------------------|
| Fast network, small component | 200ms (default) |
| Known heavy component | 100ms |
| Interactive element user is waiting for | 50-100ms |
| Background content load | 300-500ms |
## Key Points
1. The default 200ms delay is a good choice for most cases
2. Never set `delay: 0` unless you explicitly want the loading state visible immediately
3. Pair `delay` with `timeout` for complete loading state management
4. Consider your network conditions and component size when tuning delay
## References
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)

View File

@@ -0,0 +1,74 @@
# Async Components Are Suspensible by Default
## Rule
Async components created with `defineAsyncComponent` are automatically treated as async dependencies of any parent `<Suspense>` component. When wrapped by `<Suspense>`, the async component's own `loadingComponent`, `errorComponent`, `delay`, and `timeout` options are ignored.
## Why This Matters
This behavior causes confusion when developers configure loading and error states on their async components but these states never appear because a parent `<Suspense>` takes over control. The component's options are silently ignored, leading to unexpected behavior.
## Bad Code
```vue
<script setup>
import { defineAsyncComponent } from 'vue'
// These options will be IGNORED if a parent Suspense exists
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner, // Won't show!
errorComponent: ErrorDisplay, // Won't show!
timeout: 3000 // Ignored!
})
</script>
<template>
<!-- If this is inside a Suspense somewhere up the tree -->
<AsyncDashboard />
</template>
```
## Good Code
```vue
<script setup>
import { defineAsyncComponent } from 'vue'
// Use suspensible: false to keep control of loading/error states
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
timeout: 3000,
suspensible: false // Component controls its own loading state
})
</script>
<template>
<AsyncDashboard />
</template>
```
## When to Use Each Approach
**Keep suspensible (default)** when:
- You want centralized loading/error handling at a layout level
- The parent `<Suspense>` provides appropriate feedback
- Multiple async components should show a unified loading state
**Use `suspensible: false`** when:
- You need component-specific loading indicators
- The component should handle its own error states
- You want fine-grained control over the UX
## Key Points
1. Check if your component tree has a `<Suspense>` ancestor before relying on async component options
2. Use `suspensible: false` explicitly when you need the component to manage its own states
3. The `<Suspense>` component's `#fallback` slot and `onErrorCaptured` take precedence over async component options
## References
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)
- [Vue.js Suspense Documentation](https://vuejs.org/guide/built-ins/suspense)

View File

@@ -0,0 +1,109 @@
# Do Not Use defineAsyncComponent with Vue Router
## Rule
Never use `defineAsyncComponent` when configuring Vue Router route components. Vue Router has its own lazy loading mechanism using dynamic imports directly.
## Why This Matters
Vue Router's lazy loading is specifically designed for route-level code splitting. Using `defineAsyncComponent` for routes adds unnecessary overhead and can cause unexpected behavior with navigation guards, loading states, and route transitions.
## Bad Code
```javascript
import { defineAsyncComponent } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
// WRONG: Don't use defineAsyncComponent here
component: defineAsyncComponent(() =>
import('./views/Dashboard.vue')
)
},
{
path: '/profile',
// WRONG: This also won't work as expected
component: defineAsyncComponent({
loader: () => import('./views/Profile.vue'),
loadingComponent: LoadingSpinner
})
}
]
})
```
## Good Code
```javascript
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
// CORRECT: Use dynamic import directly
component: () => import('./views/Dashboard.vue')
},
{
path: '/profile',
// CORRECT: Simple arrow function with import
component: () => import('./views/Profile.vue')
}
]
})
```
## Handling Loading States with Vue Router
For route-level loading states, use Vue Router's navigation guards or a global loading indicator:
```vue
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const isLoading = ref(false)
router.beforeEach(() => {
isLoading.value = true
})
router.afterEach(() => {
isLoading.value = false
})
</script>
<template>
<LoadingBar v-if="isLoading" />
<RouterView />
</template>
```
## When to Use defineAsyncComponent
Use `defineAsyncComponent` for:
- Components loaded conditionally within a page
- Heavy components that aren't always needed
- Modal dialogs or panels that load on demand
Use Vue Router's lazy loading for:
- Route-level components (views/pages)
- Any component configured in route definitions
## Key Points
1. Vue Router and `defineAsyncComponent` are separate lazy loading mechanisms
2. Route components should use direct dynamic imports: `() => import('./View.vue')`
3. Use navigation guards for route-level loading indicators
4. `defineAsyncComponent` is for component-level lazy loading within pages
## References
- [Vue Router Lazy Loading Routes](https://router.vuejs.org/guide/advanced/lazy-loading.html)
- [Vue.js Async Components Documentation](https://vuejs.org/guide/components/async)

View File

@@ -0,0 +1,186 @@
# Accessing Hyphenated Attributes in $attrs
## Rule
Fallthrough attributes preserve their original casing in JavaScript. Hyphenated attribute names (like `data-testid` or `aria-label`) must be accessed using bracket notation. Event listeners are exposed as camelCase functions (e.g., `@click` becomes `$attrs.onClick`).
## Why This Matters
- JavaScript identifiers cannot contain hyphens
- Using dot notation with hyphenated names causes syntax errors or undefined values
- Event listener naming follows a different convention than attribute naming
- Common source of "undefined" errors when working with attrs programmatically
## Bad Code
```vue
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
// WRONG: Syntax error - hyphen interpreted as minus
console.log(attrs.data-testid) // Error!
// WRONG: This accesses a different property
console.log(attrs.dataTestid) // undefined (camelCase doesn't work for attrs)
// WRONG: Expecting hyphenated event name
console.log(attrs['on-click']) // undefined
console.log(attrs['@click']) // undefined
</script>
```
## Good Code
```vue
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
// CORRECT: Use bracket notation for hyphenated attributes
console.log(attrs['data-testid']) // "my-button"
console.log(attrs['aria-label']) // "Submit form"
console.log(attrs['foo-bar']) // "baz"
// CORRECT: Event listeners use camelCase with 'on' prefix
console.log(attrs.onClick) // function
console.log(attrs.onCustomEvent) // function (from @custom-event)
console.log(attrs.onMouseEnter) // function (from @mouseenter or @mouse-enter)
</script>
```
## Attribute vs Event Naming Reference
| Parent Usage | $attrs Access |
|--------------|---------------|
| `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']` |
## Common Patterns
### Checking for specific attributes
```vue
<script setup>
import { useAttrs, computed } from 'vue'
const attrs = useAttrs()
// Check if data attribute exists
const hasTestId = computed(() => 'data-testid' in attrs)
// Get aria attribute with default
const ariaLabel = computed(() => attrs['aria-label'] ?? 'Default label')
</script>
```
### Filtering attributes by type
```vue
<script setup>
import { useAttrs, computed } from 'vue'
const attrs = useAttrs()
// Separate event listeners from other attributes
const { listeners, otherAttrs } = computed(() => {
const listeners = {}
const otherAttrs = {}
for (const [key, value] of Object.entries(attrs)) {
if (key.startsWith('on') && typeof value === 'function') {
listeners[key] = value
} else {
otherAttrs[key] = value
}
}
return { listeners, otherAttrs }
}).value
</script>
```
### Extracting data attributes
```vue
<script setup>
import { useAttrs, computed } from 'vue'
const attrs = useAttrs()
// Get all data-* attributes
const dataAttrs = computed(() => {
const result = {}
for (const [key, value] of Object.entries(attrs)) {
if (key.startsWith('data-')) {
result[key] = value
}
}
return result
})
</script>
<template>
<div v-bind="dataAttrs">
<!-- Only data attributes are bound -->
</div>
</template>
```
### Forwarding specific events
```vue
<script setup>
import { useAttrs } from 'vue'
defineOptions({
inheritAttrs: false
})
const attrs = useAttrs()
// Call parent's click handler with custom logic
function handleClick(event) {
console.log('Internal handling first')
// Then forward to parent if handler exists
if (attrs.onClick) {
attrs.onClick(event)
}
}
</script>
<template>
<button @click="handleClick">
<slot />
</button>
</template>
```
## TypeScript Considerations
```vue
<script setup lang="ts">
import { useAttrs } from 'vue'
const attrs = useAttrs()
// attrs is typed as Record<string, unknown>
// You may need to cast for specific usage
const testId = attrs['data-testid'] as string | undefined
const onClick = attrs.onClick as ((e: MouseEvent) => void) | undefined
</script>
```
## References
- [Fallthrough Attributes - Accessing in JavaScript](https://vuejs.org/guide/components/attrs.html#accessing-fallthrough-attributes-in-javascript)
- [Vue 3 $attrs Documentation](https://vuejs.org/api/component-instance.html#attrs)

View File

@@ -0,0 +1,162 @@
---
title: useAttrs() Object Is Not Reactive
impact: MEDIUM
impactDescription: Watching attrs directly does not trigger - use onUpdated() or convert to props
type: gotcha
tags: [vue3, attrs, reactivity, composition-api]
---
# useAttrs() Object Is Not Reactive
**Impact: MEDIUM** - The object returned by `useAttrs()` is NOT reactive. While it always reflects the latest fallthrough attributes, you cannot use `watch()` or `watchEffect()` to observe its changes. Watchers on attrs properties will NOT trigger when attributes change.
## Task Checklist
- [ ] Never use `watch()` to observe attrs changes - it won't trigger
- [ ] Use `onUpdated()` lifecycle hook for side effects based on attrs
- [ ] Convert frequently-accessed attrs to props if you need reactivity
- [ ] Remember attrs ARE always current in templates and event handlers
**Incorrect:**
```vue
<script setup>
import { watch, useAttrs } from 'vue'
const attrs = useAttrs()
// WRONG: This watcher will NEVER trigger when attrs change!
watch(
() => attrs.someAttr,
(newValue) => {
console.log('Attribute changed:', newValue)
// This callback never runs on attr changes
}
)
// WRONG: watchEffect also doesn't track attrs
watchEffect(() => {
console.log(attrs.class) // Only runs on mount, NOT when class changes
})
</script>
```
**Correct:**
```vue
<script setup>
import { onUpdated, useAttrs } from 'vue'
const attrs = useAttrs()
// CORRECT: Use onUpdated to access latest attrs
onUpdated(() => {
console.log('Current attrs:', attrs)
// Perform side effects with latest attrs here
})
</script>
```
```vue
<script setup>
import { watch } from 'vue'
// CORRECT: If you need reactivity, declare it as a prop
const props = defineProps({
someAttr: String
})
// Now you can watch it
watch(
() => props.someAttr,
(newValue) => {
console.log('Attribute changed:', newValue)
}
)
</script>
```
## Why Attrs Are Not Reactive
Vue's official documentation states:
> "Note that although the attrs object here always reflects the latest fallthrough attributes, it isn't reactive (for performance reasons). You cannot use watchers to observe its changes."
This is a deliberate design decision for performance - making attrs reactive would add overhead to every component that uses fallthrough attributes.
## When Attrs DO Reflect Current Values
Despite not being reactive, attrs always have current values in these contexts:
```vue
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
// In event handlers - attrs are current when handler executes
function handleClick() {
console.log(attrs.class) // Current value at click time
}
</script>
<template>
<!-- In templates - always current -->
<div :class="attrs.class">
{{ attrs.title }}
</div>
<!-- Using v-bind to spread all attrs -->
<input v-bind="attrs" />
</template>
```
## Computed Properties with Attrs
Computed properties that reference attrs will update when the component re-renders:
```vue
<script setup>
import { computed, useAttrs } from 'vue'
const attrs = useAttrs()
// This DOES update - because computed re-evaluates on render
const hasCustomClass = computed(() => {
return !!attrs.class
})
</script>
<template>
<div v-bind="attrs">
<span v-if="hasCustomClass">Has custom styling</span>
</div>
</template>
```
Note: The computed updates because the component re-renders when props/attrs change, not because attrs is reactive.
## Alternative: Use getCurrentInstance() (Advanced)
For advanced use cases, you can access attrs through the component instance:
```vue
<script setup>
import { watch, getCurrentInstance } from 'vue'
const instance = getCurrentInstance()
// This may work in some cases, but is NOT officially supported
// getCurrentInstance() is an internal API and may change
watch(
() => instance?.attrs.someAttr,
(newValue) => {
console.log('Attribute changed:', newValue)
}
)
</script>
```
> **Warning:** `getCurrentInstance()` is an internal API. Prefer `onUpdated()` or converting to props.
## Reference
- [Fallthrough Attributes - Accessing in JavaScript](https://vuejs.org/guide/components/attrs.html#accessing-fallthrough-attributes-in-javascript)
- [Vue 3 Reactivity Fundamentals](https://vuejs.org/guide/essentials/reactivity-fundamentals.html)

View File

@@ -0,0 +1,249 @@
---
title: Avoid Prop Drilling - Use Provide/Inject for Deep Component Trees
impact: MEDIUM
impactDescription: Passing props through many layers creates maintenance burden and tight coupling between intermediate components
type: best-practice
tags: [vue3, props, provide-inject, component-design, state-management, architecture]
---
# Avoid Prop Drilling - Use Provide/Inject for Deep Component Trees
**Impact: MEDIUM** - Prop drilling occurs when you pass props through multiple component layers just to reach a deeply nested child. This creates tight coupling, makes refactoring difficult, and clutters intermediate components with props they don't use.
Vue's provide/inject API allows ancestor components to share data with any descendant, regardless of nesting depth.
## Task Checklist
- [ ] Identify when props pass through 2+ intermediate components unchanged
- [ ] Use provide/inject for data needed by deeply nested descendants
- [ ] Use Pinia for global state shared across unrelated component trees
- [ ] Keep props for direct parent-child relationships
- [ ] Document provided values at the provider level
## The Problem: Prop Drilling
```vue
<!-- App.vue -->
<template>
<MainLayout :user="user" :theme="theme" :locale="locale" />
</template>
```
```vue
<!-- MainLayout.vue - Doesn't use these props, just passes them -->
<template>
<Sidebar :user="user" :theme="theme" />
<Content :user="user" :locale="locale" />
</template>
```
```vue
<!-- Sidebar.vue - Still drilling... -->
<template>
<UserMenu :user="user" />
<ThemeToggle :theme="theme" />
</template>
```
```vue
<!-- UserMenu.vue - Finally uses user prop -->
<template>
<div>{{ user.name }}</div>
</template>
```
**Problems:**
1. `MainLayout` and `Sidebar` are cluttered with props they don't use
2. Adding a new shared value requires updating every component in the chain
3. Removing a deeply nested component requires updating all ancestors
4. Difficult to trace where data originates
## Solution: Provide/Inject
**Correct - Provider (ancestor):**
```vue
<!-- App.vue -->
<script setup>
import { provide, ref, readonly } from 'vue'
const user = ref({ name: 'John', role: 'admin' })
const theme = ref('dark')
const locale = ref('en')
// Provide to all descendants
provide('user', readonly(user)) // readonly prevents mutations
provide('theme', theme)
provide('locale', locale)
// Provide update functions if needed
provide('updateTheme', (newTheme) => {
theme.value = newTheme
})
</script>
<template>
<MainLayout />
</template>
```
**Correct - Intermediate components are now clean:**
```vue
<!-- MainLayout.vue - No props needed -->
<template>
<Sidebar />
<Content />
</template>
```
```vue
<!-- Sidebar.vue - No props needed -->
<template>
<UserMenu />
<ThemeToggle />
</template>
```
**Correct - Consumer (descendant):**
```vue
<!-- UserMenu.vue -->
<script setup>
import { inject } from 'vue'
// Inject from any ancestor
const user = inject('user')
</script>
<template>
<div>{{ user.name }}</div>
</template>
```
```vue
<!-- ThemeToggle.vue -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const updateTheme = inject('updateTheme')
function toggleTheme() {
updateTheme(theme.value === 'dark' ? 'light' : 'dark')
}
</script>
<template>
<button @click="toggleTheme">
Current: {{ theme }}
</button>
</template>
```
## Best Practices for Provide/Inject
### 1. Use Symbol Keys for Large Apps
Avoid string key collisions with symbols:
```js
// keys.js
export const UserKey = Symbol('user')
export const ThemeKey = Symbol('theme')
```
```vue
<script setup>
import { provide } from 'vue'
import { UserKey, ThemeKey } from './keys'
provide(UserKey, user)
provide(ThemeKey, theme)
</script>
```
```vue
<script setup>
import { inject } from 'vue'
import { UserKey } from './keys'
const user = inject(UserKey)
</script>
```
### 2. Provide Default Values
Handle cases where no ancestor provides the value:
```vue
<script setup>
import { inject } from 'vue'
// With default value
const theme = inject('theme', 'light')
// With factory function for objects (avoids shared reference)
const config = inject('config', () => ({ debug: false }), true)
</script>
```
### 3. Use Readonly for Data Safety
Prevent descendants from mutating provided data:
```vue
<script setup>
import { provide, ref, readonly } from 'vue'
const user = ref({ name: 'John' })
// Descendants can read but not mutate
provide('user', readonly(user))
// Provide separate method for updates
provide('updateUser', (updates) => {
Object.assign(user.value, updates)
})
</script>
```
### 4. Provide Computed Values for Reactivity
```vue
<script setup>
import { provide, computed } from 'vue'
const items = ref([1, 2, 3])
// Descendants will reactively update
provide('itemCount', computed(() => items.value.length))
</script>
```
## When to Use What
| Scenario | Solution |
|----------|----------|
| Direct parent-child | Props |
| 1-2 levels deep | Props (drilling is acceptable) |
| Deep nesting, same component tree | Provide/Inject |
| Unrelated component trees | Pinia (state management) |
| Cross-app global state | Pinia |
| Plugin configuration | Provide/Inject from plugin install |
## Provide/Inject vs Pinia
**Provide/Inject:**
- Scoped to component subtree
- Great for component library internals
- No DevTools support
- Ancestor-descendant relationships only
**Pinia:**
- Global, accessible anywhere
- Excellent DevTools integration
- Better for application state
- Works across unrelated components
## Reference
- [Vue.js Provide/Inject](https://vuejs.org/guide/components/provide-inject.html)
- [Vue.js - Prop Drilling](https://vuejs.org/guide/components/provide-inject.html#prop-drilling)
- [Pinia Documentation](https://pinia.vuejs.org/)

View File

@@ -0,0 +1,252 @@
---
title: Component Events Don't Bubble
impact: MEDIUM
impactDescription: Vue component events only reach direct parent - sibling and grandparent communication requires alternative patterns
type: gotcha
tags: [vue3, events, emit, event-bubbling, provide-inject, state-management]
---
# Component Events Don't Bubble
**Impact: MEDIUM** - Unlike native DOM events, Vue component events do NOT bubble up the component tree. When a child emits an event, only its direct parent can listen for it. Grandparent components and siblings never receive the event.
This is a common source of confusion for developers coming from vanilla JavaScript where events naturally bubble up the DOM.
## Task Checklist
- [ ] Only expect events from direct child components
- [ ] Use provide/inject for deeply nested communication
- [ ] Use state management (Pinia) for complex cross-component communication
- [ ] Re-emit events at each level if manual bubbling is needed
- [ ] Consider whether your component hierarchy is too deep
## The Problem
```vue
<!-- GrandParent.vue -->
<template>
<!-- This handler NEVER fires for grandchild events -->
<Parent @item-selected="handleSelect" />
</template>
```
```vue
<!-- Parent.vue -->
<template>
<Child />
</template>
```
```vue
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['item-selected'])
function selectItem(item) {
// This event reaches Parent, but NOT GrandParent
emit('item-selected', item)
}
</script>
```
## Solution 1: Re-emit at Each Level (Simple Cases)
Manually forward events through each component.
**Correct:**
```vue
<!-- GrandParent.vue -->
<template>
<Parent @item-selected="handleSelect" />
</template>
```
```vue
<!-- Parent.vue -->
<script setup>
const emit = defineEmits(['item-selected'])
</script>
<template>
<!-- Re-emit to grandparent -->
<Child @item-selected="(item) => emit('item-selected', item)" />
</template>
```
```vue
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['item-selected'])
function selectItem(item) {
emit('item-selected', item)
}
</script>
```
**Drawback:** Becomes tedious with deeply nested components.
## Solution 2: Provide/Inject (Ancestor Communication)
For deeply nested components, provide a callback from the ancestor.
**Correct:**
```vue
<!-- GrandParent.vue -->
<script setup>
import { provide } from 'vue'
function handleItemSelected(item) {
console.log('Item selected:', item)
}
// Provide the callback to all descendants
provide('onItemSelected', handleItemSelected)
</script>
<template>
<Parent />
</template>
```
```vue
<!-- Parent.vue - No changes needed -->
<template>
<Child />
</template>
```
```vue
<!-- Child.vue -->
<script setup>
import { inject } from 'vue'
// Inject the callback from any ancestor
const onItemSelected = inject('onItemSelected', () => {})
function selectItem(item) {
onItemSelected(item)
}
</script>
```
**Advantages:**
- Skips intermediate components
- No prop drilling or re-emitting
- Works at any nesting depth
## Solution 3: State Management (Complex Applications)
For cross-component communication, especially between siblings or unrelated components, use Pinia.
**Correct:**
```js
// stores/selection.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSelectionStore = defineStore('selection', () => {
const selectedItem = ref(null)
function selectItem(item) {
selectedItem.value = item
}
return { selectedItem, selectItem }
})
```
```vue
<!-- DeepChild.vue - Updates state -->
<script setup>
import { useSelectionStore } from '@/stores/selection'
const store = useSelectionStore()
function handleSelect(item) {
store.selectItem(item)
}
</script>
```
```vue
<!-- SiblingComponent.vue - Reacts to state -->
<script setup>
import { useSelectionStore } from '@/stores/selection'
const store = useSelectionStore()
</script>
<template>
<div v-if="store.selectedItem">
Selected: {{ store.selectedItem.name }}
</div>
</template>
```
## Solution 4: Event Bus (Use Sparingly)
For truly decoupled components, a simple event bus can work:
```js
// eventBus.js
import mitt from 'mitt'
export const emitter = mitt()
```
```vue
<!-- ComponentA.vue -->
<script setup>
import { emitter } from './eventBus'
function notify() {
emitter.emit('custom-event', { data: 'value' })
}
</script>
```
```vue
<!-- ComponentB.vue -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { emitter } from './eventBus'
function handleEvent(data) {
console.log('Received:', data)
}
onMounted(() => emitter.on('custom-event', handleEvent))
onUnmounted(() => emitter.off('custom-event', handleEvent))
</script>
```
**Warning:** Event buses make data flow hard to trace. Prefer provide/inject or state management.
## Comparison Table
| Method | Best For | Complexity |
|--------|----------|------------|
| Re-emit | 1-2 levels deep | Low |
| Provide/Inject | Deep nesting, ancestor communication | Medium |
| Pinia/State | Complex apps, sibling communication | Medium |
| Event Bus | Truly decoupled, rare cases | Low (but risky) |
## Native Events DO Bubble
Note that native DOM events attached to elements still bubble normally:
```vue
<!-- GrandParent.vue -->
<template>
<!-- Native click bubbles up from button inside Child -->
<div @click="handleClick">
<Parent />
</div>
</template>
```
Only Vue component events (those emitted with `emit()`) don't bubble.
## Reference
- [Vue.js Component Events](https://vuejs.org/guide/components/events.html)
- [Vue.js Provide/Inject](https://vuejs.org/guide/components/provide-inject.html)

View File

@@ -0,0 +1,114 @@
---
title: Use PascalCase for Component Names
impact: LOW
impactDescription: Improves code clarity and IDE support, but both cases work
type: best-practice
tags: [vue3, component-registration, naming-conventions, pascalcase, ide-support]
---
# Use PascalCase for Component Names
**Impact: LOW** - Vue supports both PascalCase (`<MyComponent>`) and kebab-case (`<my-component>`) in templates, but PascalCase is recommended. It provides better IDE support, clearly distinguishes Vue components from native HTML elements, and avoids confusion with web components (custom elements).
## Task Checklist
- [ ] Name component files in PascalCase (e.g., `UserProfile.vue`)
- [ ] Use PascalCase when referencing components in templates
- [ ] Use kebab-case only when required (in-DOM templates)
- [ ] Be consistent across the entire codebase
**Less Ideal:**
```vue
<script setup>
import userProfile from './user-profile.vue'
</script>
<template>
<!-- Works but harder to distinguish from HTML elements -->
<user-profile :user="currentUser" />
<header>Native HTML</header>
<footer>Native HTML</footer>
</template>
```
**Recommended:**
```vue
<script setup>
import UserProfile from './UserProfile.vue'
</script>
<template>
<!-- Clear visual distinction: components vs HTML elements -->
<UserProfile :user="currentUser" />
<header>Native HTML</header>
<footer>Native HTML</footer>
</template>
```
## Why PascalCase?
### 1. Visual Distinction
```vue
<template>
<!-- Immediately clear what's a component vs native HTML -->
<PageHeader /> <!-- Component -->
<header>...</header> <!-- Native HTML -->
<NavigationMenu /> <!-- Component -->
<nav>...</nav> <!-- Native HTML -->
<UserAvatar /> <!-- Component -->
<img src="..." /> <!-- Native HTML -->
</template>
```
### 2. IDE Auto-completion
PascalCase names are valid JavaScript identifiers, enabling better IDE support:
- Auto-import suggestions
- Go-to-definition
- Refactoring tools
### 3. Avoids Web Component Confusion
Web Components (custom elements) require kebab-case with a hyphen. Using PascalCase for Vue components avoids any confusion:
```vue
<template>
<!-- Vue component -->
<MyButton @click="handle" />
<!-- Web component (custom element) -->
<my-custom-element></my-custom-element>
</template>
```
## Exception: In-DOM Templates
When using in-DOM templates (HTML files without build step), you MUST use kebab-case because HTML is case-insensitive:
```html
<!-- index.html - in-DOM template -->
<div id="app">
<!-- PascalCase won't work in HTML files -->
<!-- <UserProfile></UserProfile> --> <!-- WRONG -->
<!-- Must use kebab-case -->
<user-profile :user="currentUser"></user-profile>
</div>
```
## Vue's Automatic Resolution
Vue automatically resolves PascalCase components to both casings:
```vue
<script setup>
import MyComponent from './MyComponent.vue'
</script>
<template>
<!-- Both work, but PascalCase is preferred -->
<MyComponent />
<my-component />
</template>
```
## Reference
- [Vue.js Component Registration - Component Name Casing](https://vuejs.org/guide/components/registration.html#component-name-casing)

View File

@@ -0,0 +1,208 @@
---
title: Avoid Hidden Side Effects in Composables
impact: HIGH
impactDescription: Side effects hidden in composables make debugging difficult and create implicit coupling between components
type: best-practice
tags: [vue3, composables, composition-api, side-effects, provide-inject, global-state]
---
# Avoid Hidden Side Effects in Composables
**Impact: HIGH** - Composables should encapsulate stateful logic, not hide side effects that affect things outside their scope. Hidden side effects like modifying global state, using provide/inject internally, or manipulating the DOM directly make composables unpredictable and hard to debug.
When a composable has unexpected side effects, consumers can't reason about what calling it will do. This leads to bugs that are difficult to trace and composables that can't be safely reused.
## Task Checklist
- [ ] Avoid using provide/inject inside composables (make dependencies explicit)
- [ ] Don't modify Pinia/Vuex store state internally (accept store as parameter instead)
- [ ] Don't manipulate DOM directly (use template refs passed as arguments)
- [ ] Document any unavoidable side effects clearly
- [ ] Keep composables focused on returning reactive state and methods
**Incorrect:**
```javascript
// WRONG: Hidden provide/inject dependency
export function useTheme() {
// Consumer has no idea this depends on a provided theme
const theme = inject('theme') // What if nothing provides this?
const isDark = computed(() => theme?.mode === 'dark')
return { isDark }
}
// WRONG: Modifying global store internally
import { useUserStore } from '@/stores/user'
export function useLogin() {
const userStore = useUserStore()
async function login(credentials) {
const user = await api.login(credentials)
// Hidden side effect: modifying global state
userStore.setUser(user)
userStore.setToken(user.token)
// Consumer doesn't know the store was modified!
}
return { login }
}
// WRONG: Hidden DOM manipulation
export function useFocusTrap() {
onMounted(() => {
// Which element? Consumer has no control
document.querySelector('.modal')?.focus()
})
}
// WRONG: Hidden provide that affects descendants
export function useFormContext() {
const form = reactive({ values: {}, errors: {} })
// Components calling this have no idea it provides something
provide('form-context', form)
return form
}
```
**Correct:**
```javascript
// CORRECT: Explicit dependency injection
export function useTheme(injectedTheme) {
// If no theme passed, consumer must handle it
const theme = injectedTheme ?? { mode: 'light' }
const isDark = computed(() => theme.mode === 'dark')
return { isDark }
}
// Usage - dependency is explicit
const theme = inject('theme', { mode: 'light' })
const { isDark } = useTheme(theme)
// CORRECT: Return actions, let consumer decide when to call them
export function useLogin() {
const user = ref(null)
const token = ref(null)
const isLoading = ref(false)
const error = ref(null)
async function login(credentials) {
isLoading.value = true
error.value = null
try {
const response = await api.login(credentials)
user.value = response.user
token.value = response.token
return response
} catch (e) {
error.value = e
throw e
} finally {
isLoading.value = false
}
}
return { user, token, isLoading, error, login }
}
// Consumer decides what to do with the result
const { user, token, login } = useLogin()
const userStore = useUserStore()
async function handleLogin(credentials) {
await login(credentials)
// Consumer explicitly updates the store
userStore.setUser(user.value)
userStore.setToken(token.value)
}
// CORRECT: Accept element as parameter
export function useFocusTrap(targetRef) {
onMounted(() => {
targetRef.value?.focus()
})
onUnmounted(() => {
// Cleanup focus trap
})
}
// Usage - consumer controls which element
const modalRef = ref(null)
useFocusTrap(modalRef)
// CORRECT: Separate composable from provider
export function useFormContext() {
const form = reactive({ values: {}, errors: {} })
return form
}
// In parent component - explicit provide
const form = useFormContext()
provide('form-context', form)
```
## Acceptable Side Effects (With Documentation)
Some side effects are acceptable when they're the core purpose of the composable:
```javascript
/**
* Tracks mouse position globally.
*
* SIDE EFFECTS:
* - Adds 'mousemove' event listener to window (cleaned up on unmount)
*
* @returns {Object} Mouse coordinates { x, y }
*/
export function useMouse() {
const x = ref(0)
const y = ref(0)
// This side effect is the whole point of the composable
// and is properly cleaned up
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
return { x, y }
}
```
## Pattern: Dependency Injection for Flexibility
```javascript
// Composable accepts its dependencies
export function useDataFetcher(apiClient, cache = null) {
const data = ref(null)
async function fetch(url) {
if (cache) {
const cached = cache.get(url)
if (cached) {
data.value = cached
return
}
}
data.value = await apiClient.get(url)
cache?.set(url, data.value)
}
return { data, fetch }
}
// Usage - dependencies are explicit and testable
const apiClient = inject('apiClient')
const cache = inject('cache', null)
const { data, fetch } = useDataFetcher(apiClient, cache)
```
## Reference
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
- [Common Mistakes Creating Composition Functions](https://www.telerik.com/blogs/common-mistakes-creating-composition-functions-vue)

View File

@@ -0,0 +1,236 @@
---
title: Compose Composables for Complex Logic
impact: MEDIUM
impactDescription: Building composables from other composables creates reusable, testable building blocks
type: best-practice
tags: [vue3, composables, composition-api, patterns, code-organization]
---
# Compose Composables for Complex Logic
**Impact: MEDIUM** - Composables can (and should) call other composables. This composition pattern allows you to build complex functionality from smaller, focused, reusable pieces. Each composable handles one concern, and higher-level composables combine them.
This is one of the key advantages of the Composition API over mixins - dependencies are explicit and traceable.
## Task Checklist
- [ ] Extract reusable logic into focused, single-purpose composables
- [ ] Build complex composables by combining simpler ones
- [ ] Ensure each composable has a single responsibility
- [ ] Pass data between composed composables via parameters or refs
**Example: Building a Mouse Tracker from Smaller Composables**
```javascript
// composables/useEventListener.js - Low-level building block
import { onMounted, onUnmounted, toValue } from 'vue'
export function useEventListener(target, event, callback) {
onMounted(() => {
const el = toValue(target)
el.addEventListener(event, callback)
})
onUnmounted(() => {
const el = toValue(target)
el.removeEventListener(event, callback)
})
}
// composables/useMouse.js - Composes useEventListener
import { ref } from 'vue'
import { useEventListener } from './useEventListener'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
// Reuse the event listener composable
useEventListener(window, 'mousemove', update)
return { x, y }
}
// composables/useMouseInElement.js - Composes useMouse
import { ref, computed } from 'vue'
import { useMouse } from './useMouse'
export function useMouseInElement(elementRef) {
const { x, y } = useMouse()
const elementX = computed(() => {
if (!elementRef.value) return 0
const rect = elementRef.value.getBoundingClientRect()
return x.value - rect.left
})
const elementY = computed(() => {
if (!elementRef.value) return 0
const rect = elementRef.value.getBoundingClientRect()
return y.value - rect.top
})
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, elementX, elementY, isOutside }
}
```
## Pattern: Composable Dependency Chain
```javascript
// Layer 1: Primitives
export function useEventListener(target, event, callback) { /* ... */ }
export function useInterval(callback, delay) { /* ... */ }
export function useTimeout(callback, delay) { /* ... */ }
// Layer 2: Building on primitives
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
useEventListener(window, 'resize', () => {
width.value = window.innerWidth
height.value = window.innerHeight
})
return { width, height }
}
export function useOnline() {
const isOnline = ref(navigator.onLine)
useEventListener(window, 'online', () => isOnline.value = true)
useEventListener(window, 'offline', () => isOnline.value = false)
return { isOnline }
}
// Layer 3: Complex features combining multiple composables
export function useAutoSave(dataRef, saveFunction, options = {}) {
const { debounce = 1000, onlyWhenOnline = true } = options
const { isOnline } = useOnline()
const isSaving = ref(false)
const lastSaved = ref(null)
let timeoutId = null
watch(dataRef, (newData) => {
if (onlyWhenOnline && !isOnline.value) return
clearTimeout(timeoutId)
timeoutId = setTimeout(async () => {
isSaving.value = true
try {
await saveFunction(newData)
lastSaved.value = new Date()
} finally {
isSaving.value = false
}
}, debounce)
}, { deep: true })
return { isSaving, lastSaved, isOnline }
}
```
## Pattern: Code Organization with Composition
Extract inline composables when a component gets complex:
```vue
<script setup>
// BEFORE: All logic mixed together
import { ref, computed, watch, onMounted } from 'vue'
const searchQuery = ref('')
const filters = ref({ category: null, minPrice: 0 })
const products = ref([])
const isLoading = ref(false)
const error = ref(null)
const sortBy = ref('name')
const sortOrder = ref('asc')
// ...50 more lines of mixed concerns
</script>
```
```vue
<script setup>
// AFTER: Separated into focused composables
import { useProductSearch } from './composables/useProductSearch'
import { useProductFilters } from './composables/useProductFilters'
import { useProductSort } from './composables/useProductSort'
const { searchQuery, debouncedQuery } = useProductSearch()
const { filters, activeFilters, clearFilters } = useProductFilters()
const { sortBy, sortOrder, sortedProducts } = useProductSort()
// Each composable is focused, testable, and potentially reusable
</script>
```
## Passing Data Between Composed Composables
```javascript
// Composables can accept refs from other composables
export function useFilteredProducts(products, filters) {
return computed(() => {
let result = toValue(products)
if (filters.value.category) {
result = result.filter(p => p.category === filters.value.category)
}
if (filters.value.minPrice > 0) {
result = result.filter(p => p.price >= filters.value.minPrice)
}
return result
})
}
export function useSortedProducts(products, sortConfig) {
return computed(() => {
const items = [...toValue(products)]
const { field, order } = sortConfig.value
return items.sort((a, b) => {
const comparison = a[field] > b[field] ? 1 : -1
return order === 'asc' ? comparison : -comparison
})
})
}
// Usage - composables are chained through their outputs
const { products, isLoading } = useFetch('/api/products')
const { filters } = useFilters()
const filteredProducts = useFilteredProducts(products, filters)
const { sortConfig } = useSortConfig()
const sortedProducts = useSortedProducts(filteredProducts, sortConfig)
```
## Advantages Over Mixins
| Composables | Mixins |
|-------------|--------|
| Explicit dependencies via imports | Implicit dependencies |
| Clear data flow via parameters | Unclear which mixin provides what |
| No namespace collisions | Properties can conflict |
| Easy to trace and debug | Hard to track origins |
| TypeScript-friendly | Poor TypeScript support |
## Reference
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
- [Vue.js Composables vs Mixins](https://vuejs.org/guide/reusability/composables.html#comparisons-with-other-techniques)

View File

@@ -0,0 +1,139 @@
---
title: Follow Composable Naming Convention and Return Pattern
impact: MEDIUM
impactDescription: Inconsistent composable patterns lead to confusing APIs and reactivity issues when destructuring
type: best-practice
tags: [vue3, composables, composition-api, naming, conventions, refs]
---
# Follow Composable Naming Convention and Return Pattern
**Impact: MEDIUM** - Vue composables should follow established conventions: prefix names with "use" and return plain objects containing refs (not reactive objects). Returning reactive objects causes reactivity loss when destructuring, while inconsistent naming makes code harder to understand.
## Task Checklist
- [ ] Name composables with "use" prefix (e.g., `useMouse`, `useFetch`, `useAuth`)
- [ ] Return a plain object containing refs, not a reactive object
- [ ] Allow both destructuring and object-style access
- [ ] Document the returned refs for consumers
**Incorrect:**
```javascript
// WRONG: No "use" prefix - unclear it's a composable
export function mousePosition() {
const x = ref(0)
const y = ref(0)
return { x, y }
}
// WRONG: Returning reactive object - destructuring loses reactivity
export function useMouse() {
const state = reactive({
x: 0,
y: 0
})
// When consumer destructures: const { x, y } = useMouse()
// x and y become plain values, not reactive!
return state
}
// WRONG: Returning single ref directly - inconsistent API
export function useCounter() {
const count = ref(0)
return count // Consumer must use .value everywhere
}
```
**Correct:**
```javascript
// CORRECT: "use" prefix and returns plain object with refs
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
// Return plain object containing refs
return { x, y }
}
// Consumer can destructure and keep reactivity
const { x, y } = useMouse()
watch(x, (newX) => console.log('x changed:', newX)) // Works!
// Or use as object if preferred
const mouse = useMouse()
console.log(mouse.x.value)
```
## Using reactive() Wrapper for Auto-Unwrapping
If consumers prefer auto-unwrapping (no `.value`), they can wrap the result:
```javascript
import { reactive } from 'vue'
import { useMouse } from './composables/useMouse'
// Wrapping in reactive() links the refs
const mouse = reactive(useMouse())
// Now access without .value
console.log(mouse.x) // Auto-unwrapped, still reactive
// But DON'T destructure from this!
const { x } = reactive(useMouse()) // WRONG: loses reactivity again
```
## Pattern: Returning Both State and Actions
```javascript
// Composable with state AND methods
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
// Return all refs and functions in plain object
return {
count,
doubleCount,
increment,
decrement,
reset
}
}
// Usage
const { count, doubleCount, increment, reset } = useCounter(10)
```
## Naming Convention Examples
| Good Name | Bad Name | Reason |
|-----------|----------|--------|
| `useFetch` | `fetch` | Conflicts with native fetch |
| `useAuth` | `authStore` | "Store" implies Pinia/Vuex |
| `useLocalStorage` | `localStorage` | Conflicts with native API |
| `useFormValidation` | `validateForm` | Sounds like a one-shot function |
| `useWindowSize` | `getWindowSize` | "get" implies synchronous getter |
## Reference
- [Vue.js Composables - Conventions and Best Practices](https://vuejs.org/guide/reusability/composables.html#conventions-and-best-practices)
- [Vue.js Composables - Return Values](https://vuejs.org/guide/reusability/composables.html#return-values)

View File

@@ -0,0 +1,209 @@
---
title: Use Options Object Pattern for Composable Parameters
impact: MEDIUM
impactDescription: Long parameter lists are error-prone and unclear; options objects are self-documenting and extensible
type: best-practice
tags: [vue3, composables, composition-api, api-design, typescript, patterns]
---
# Use Options Object Pattern for Composable Parameters
**Impact: MEDIUM** - When a composable accepts multiple parameters (especially optional ones), use an options object instead of positional arguments. This makes the API self-documenting, prevents argument order mistakes, and allows easy extension without breaking changes.
## Task Checklist
- [ ] Use options object when composable has more than 2-3 parameters
- [ ] Always use options object when most parameters are optional
- [ ] Provide sensible defaults via destructuring
- [ ] Type the options object for better IDE support
- [ ] Required parameters can be positional; optional ones in options
**Incorrect:**
```javascript
// WRONG: Many positional parameters - unclear and error-prone
export function useFetch(url, method, headers, timeout, retries, onError) {
// What was the 4th parameter again?
}
// Usage - which boolean is which?
const { data } = useFetch('/api/users', 'GET', null, 5000, 3, handleError)
// WRONG: Easy to get order wrong
export function useDebounce(value, delay, immediate, maxWait) {
// ...
}
// Is 500 the delay or maxWait? Is true immediate?
const debounced = useDebounce(searchQuery, 500, true, 1000)
```
**Correct:**
```javascript
// CORRECT: Options object pattern
export function useFetch(url, options = {}) {
const {
method = 'GET',
headers = {},
timeout = 30000,
retries = 0,
onError = null,
immediate = true
} = options
// Implementation...
}
// Usage - clear and self-documenting
const { data } = useFetch('/api/users', {
method: 'POST',
timeout: 5000,
retries: 3,
onError: handleError
})
// CORRECT: With TypeScript for better IDE support
interface UseFetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
headers?: Record<string, string>
timeout?: number
retries?: number
onError?: (error: Error) => void
immediate?: boolean
}
export function useFetch(url: MaybeRefOrGetter<string>, options: UseFetchOptions = {}) {
const {
method = 'GET',
headers = {},
timeout = 30000,
retries = 0,
onError = null,
immediate = true
} = options
// TypeScript now provides autocomplete for options
}
```
## Pattern: Required + Options
Keep truly required parameters positional, bundle optional ones:
```javascript
// url is always required, options are not
export function useFetch(url, options = {}) {
// ...
}
// Both key and storage are required for this to make sense
export function useStorage(key, storage, options = {}) {
const { serializer = JSON, deep = true } = options
// ...
}
// Usage
useStorage('user-prefs', localStorage, { deep: false })
```
## Pattern: Reactive Options
Options can also be reactive for dynamic behavior:
```javascript
export function useFetch(url, options = {}) {
const {
refetch = ref(true), // Can be a ref!
interval = null
} = options
watchEffect(() => {
if (toValue(refetch)) {
// Perform fetch
}
})
}
// Usage with reactive option
const shouldFetch = ref(true)
const { data } = useFetch('/api/data', { refetch: shouldFetch })
// Later, disable fetching
shouldFetch.value = false
```
## Pattern: Returning Configuration
Options objects also work well for return values:
```javascript
export function useCounter(options = {}) {
const { initial = 0, min = -Infinity, max = Infinity, step = 1 } = options
const count = ref(initial)
function increment() {
count.value = Math.min(count.value + step, max)
}
function decrement() {
count.value = Math.max(count.value - step, min)
}
function set(value) {
count.value = Math.min(Math.max(value, min), max)
}
return { count, increment, decrement, set }
}
// Clear, readable usage
const { count, increment, decrement } = useCounter({
initial: 10,
min: 0,
max: 100,
step: 5
})
```
## VueUse Convention
VueUse uses this pattern extensively:
```javascript
import { useDebounceFn, useThrottleFn, useLocalStorage } from '@vueuse/core'
// All use options objects
const debouncedFn = useDebounceFn(fn, 1000, { maxWait: 5000 })
const throttledFn = useThrottleFn(fn, 1000, { trailing: true, leading: false })
const state = useLocalStorage('key', defaultValue, {
deep: true,
listenToStorageChanges: true,
serializer: {
read: JSON.parse,
write: JSON.stringify
}
})
```
## Anti-pattern: Boolean Trap
Options objects prevent the "boolean trap":
```javascript
// BAD: What do these booleans mean?
useModal(true, false, true)
// GOOD: Self-documenting
useModal({
closable: true,
backdrop: false,
keyboard: true
})
```
## Reference
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
- [VueUse Composables](https://vueuse.org/) - Examples of options pattern
- [Good Practices for Vue Composables](https://dev.to/jacobandrewsky/good-practices-and-design-patterns-for-vue-composables-24lk)

View File

@@ -0,0 +1,221 @@
---
title: Return State as Readonly with Explicit Update Methods
impact: MEDIUM
impactDescription: Exposing mutable state directly allows uncontrolled mutations scattered throughout the codebase
type: best-practice
tags: [vue3, composables, composition-api, readonly, encapsulation, state-management]
---
# Return State as Readonly with Explicit Update Methods
**Impact: MEDIUM** - When a composable manages state that should only be modified in controlled ways, return the state as `readonly` and provide explicit methods for updates. This prevents scattered, uncontrolled mutations and makes state changes traceable and predictable.
Exposing raw refs allows any consumer to modify state directly, leading to bugs that are hard to track because mutations can happen anywhere in the codebase.
## Task Checklist
- [ ] Use `readonly()` to wrap state that shouldn't be directly modified
- [ ] Provide explicit methods for all valid state transitions
- [ ] Document the intended ways to update state
- [ ] Consider returning `shallowReadonly()` for performance with large objects
**Incorrect:**
```javascript
// WRONG: State is fully mutable by any consumer
export function useCart() {
const items = ref([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
return { items, total } // Anyone can mutate items directly!
}
// Consumer code - mutations scattered everywhere
const { items, total } = useCart()
// In component A
items.value.push({ id: 1, name: 'Widget', price: 10, quantity: 1 })
// In component B - different mutation pattern
items.value = items.value.filter(item => item.id !== 1)
// In component C - direct modification
items.value[0].quantity = 5
// Hard to track: where did this item come from? Why did quantity change?
```
**Correct:**
```javascript
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)
)
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
// Explicit, controlled mutations
function addItem(product, quantity = 1) {
const existing = items.value.find(item => item.id === product.id)
if (existing) {
existing.quantity += quantity
} else {
items.value.push({ ...product, quantity })
}
}
function removeItem(productId) {
const index = items.value.findIndex(item => item.id === productId)
if (index > -1) {
items.value.splice(index, 1)
}
}
function updateQuantity(productId, quantity) {
const item = items.value.find(item => item.id === productId)
if (item) {
item.quantity = Math.max(0, quantity)
if (item.quantity === 0) {
removeItem(productId)
}
}
}
function clearCart() {
items.value = []
}
return {
// State is readonly - can't be mutated directly
items: readonly(items),
total,
itemCount,
// Only these methods can modify state
addItem,
removeItem,
updateQuantity,
clearCart
}
}
// Consumer code - controlled mutations only
const { items, total, addItem, removeItem, updateQuantity } = useCart()
// items.value.push(...) // TypeScript error: readonly!
// items.value = [] // TypeScript error: readonly!
// Correct way - through explicit methods
addItem({ id: 1, name: 'Widget', price: 10 })
updateQuantity(1, 3)
removeItem(1)
```
## Pattern: Internal vs External State
Keep internal state private, expose readonly view:
```javascript
export function useAuth() {
// Internal, fully mutable
const _user = ref(null)
const _token = ref(null)
const _isLoading = ref(false)
const _error = ref(null)
async function login(credentials) {
_isLoading.value = true
_error.value = null
try {
const response = await api.login(credentials)
_user.value = response.user
_token.value = response.token
} catch (e) {
_error.value = e.message
throw e
} finally {
_isLoading.value = false
}
}
function logout() {
_user.value = null
_token.value = null
}
return {
// Readonly views of internal state
user: readonly(_user),
isAuthenticated: computed(() => !!_user.value),
isLoading: readonly(_isLoading),
error: readonly(_error),
// Methods for state changes
login,
logout
}
}
```
## When to Use readonly vs Not
| Use `readonly` | Don't Use `readonly` |
|----------------|----------------------|
| State with specific update rules | Simple two-way binding state |
| Shared state between components | Form input values |
| State that needs validation on change | Local component state |
| When debugging mutation sources matters | When consumers need full control |
```javascript
// Form input - consumers SHOULD mutate directly
export function useForm(initial) {
const values = ref({ ...initial })
return { values } // No readonly - it's meant to be mutated
}
// Counter with min/max - needs controlled mutations
export function useCounter(min = 0, max = 100) {
const _count = ref(min)
function increment() {
if (_count.value < max) _count.value++
}
function decrement() {
if (_count.value > min) _count.value--
}
return {
count: readonly(_count),
increment,
decrement
}
}
```
## Performance: shallowReadonly
For large objects, use `shallowReadonly` to avoid deep readonly conversion:
```javascript
export function useLargeDataset() {
const data = ref([/* thousands of items */])
return {
// shallowReadonly - only top level is readonly
// Nested properties are still technically mutable
// but the ref itself can't be reassigned
data: shallowReadonly(data)
}
}
```
## Reference
- [Vue.js Reactivity API - readonly](https://vuejs.org/api/reactivity-core.html#readonly)
- [13 Vue Composables Tips](https://michaelnthiessen.com/13-vue-composables-tips/)

View File

@@ -0,0 +1,193 @@
---
title: Don't Wrap Utility Functions as Composables
impact: MEDIUM
impactDescription: Wrapping stateless utility functions as composables adds unnecessary complexity without any benefit
type: best-practice
tags: [vue3, composables, composition-api, utilities, patterns]
---
# Don't Wrap Utility Functions as Composables
**Impact: MEDIUM** - Not every function needs to be a composable. Composables are specifically for encapsulating **stateful logic** that uses Vue's reactivity system. Pure utility functions that just transform data or perform calculations should remain as regular JavaScript functions.
Wrapping utility functions as composables adds unnecessary abstraction, makes code harder to understand, and provides no benefits since there's no reactive state to manage.
## Task Checklist
- [ ] Identify if the function manages reactive state or uses Vue lifecycle hooks
- [ ] Keep pure transformation/calculation functions as regular utilities
- [ ] Export utilities directly, not wrapped in a function that returns them
- [ ] Reserve the "use" prefix for actual composables
**Incorrect:**
```javascript
// WRONG: These are just utility functions wrapped unnecessarily
// Adds no value - no reactive state
export function useFormatters() {
const formatDate = (date) => {
return new Intl.DateTimeFormat('en-US').format(date)
}
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount)
}
const capitalize = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1)
}
return { formatDate, formatCurrency, capitalize }
}
// WRONG: Pure calculation, no reactive state
export function useMath() {
const add = (a, b) => a + b
const multiply = (a, b) => a * b
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
return { add, multiply, clamp }
}
// Usage adds ceremony for no benefit
const { formatDate, formatCurrency } = useFormatters()
const { clamp } = useMath()
```
**Correct:**
```javascript
// CORRECT: Export as regular utility functions
// 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)
}
export function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
// utils/math.js
export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max)
}
// Usage - simple and direct
import { formatDate, formatCurrency } from '@/utils/formatters'
import { clamp } from '@/utils/math'
```
## When to Use Composables vs Utilities
| Use Composable When... | Use Utility When... |
|------------------------|---------------------|
| Managing reactive state (`ref`, `reactive`) | Pure data transformation |
| Using lifecycle hooks (`onMounted`, `onUnmounted`) | Stateless calculations |
| Setting up watchers (`watch`, `watchEffect`) | String/array manipulation |
| Creating computed properties | Formatting functions |
| Needs cleanup on component unmount | Validation functions |
| State changes over time | Mathematical operations |
## Examples: Composables vs Utilities
```javascript
// COMPOSABLE: Has reactive state and lifecycle
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => window.addEventListener('resize', update))
onUnmounted(() => window.removeEventListener('resize', update))
return { width, height }
}
// UTILITY: Pure transformation, no state
export function parseQueryString(queryString) {
return Object.fromEntries(new URLSearchParams(queryString))
}
// COMPOSABLE: Manages form state over time
export function useForm(initialValues) {
const values = ref({ ...initialValues })
const errors = ref({})
const isDirty = computed(() =>
JSON.stringify(values.value) !== JSON.stringify(initialValues)
)
function reset() {
values.value = { ...initialValues }
errors.value = {}
}
return { values, errors, isDirty, reset }
}
// UTILITY: Stateless validation
export function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
export function validateRequired(value) {
return value !== null && value !== undefined && value !== ''
}
```
## Mixed Pattern: Composable Using Utilities
It's perfectly fine for composables to use utility functions:
```javascript
// utils/validators.js
export function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
// composables/useEmailInput.js
import { ref, computed } from 'vue'
import { validateEmail } from '@/utils/validators'
export function useEmailInput(initialValue = '') {
const email = ref(initialValue)
const isValid = computed(() => validateEmail(email.value))
const error = computed(() =>
email.value && !isValid.value ? 'Invalid email format' : null
)
return { email, isValid, error }
}
```
## File Organization
```
src/
composables/ # Stateful reactive logic
useAuth.js
useFetch.js
useLocalStorage.js
utils/ # Pure utility functions
formatters.js
validators.js
math.js
strings.js
```
## Reference
- [Vue.js Composables - What is a Composable](https://vuejs.org/guide/reusability/composables.html#what-is-a-composable)
- [Common Mistakes Creating Composition Functions](https://www.telerik.com/blogs/common-mistakes-creating-composition-functions-vue)

View File

@@ -0,0 +1,158 @@
---
title: Composition API Produces Smaller, More Efficient Bundles
impact: LOW
impactDescription: Understanding this helps justify Composition API adoption for performance-sensitive projects
type: efficiency
tags: [vue3, composition-api, bundle-size, minification, performance]
---
# Composition API Produces Smaller, More Efficient Bundles
**Impact: LOW** - The Composition API is more minification-friendly than the Options API, resulting in smaller production bundles and less runtime overhead. This is a beneficial side-effect rather than a primary reason to choose Composition API.
In `<script setup>`, variables and functions can have their names safely shortened by minifiers because they're local to the component scope. Options API properties (methods, computed, data) are accessed via string keys on `this`, which cannot be minified.
## Task Checklist
- [ ] Prefer `<script setup>` for optimal minification
- [ ] Understand that Composition API avoids the `this` proxy overhead
- [ ] For Options API-free projects, consider enabling the compile-time flag to drop Options API code
- [ ] Be aware that libraries using Options API will include that code regardless
**Bundle Size Difference:**
```javascript
// OPTIONS API - Before minification
export default {
data() {
return {
userAuthenticated: false,
currentUserProfile: null,
navigationMenuOpen: false
}
},
computed: {
displayUserName() {
return this.currentUserProfile?.name || 'Guest'
}
},
methods: {
handleUserAuthentication() {
this.userAuthenticated = true
}
}
}
// OPTIONS API - After minification (property names preserved)
export default {
data() {
return {
userAuthenticated: false, // Cannot shorten
currentUserProfile: null, // Cannot shorten
navigationMenuOpen: false // Cannot shorten
}
},
computed: {
displayUserName() { // Cannot shorten
return this.currentUserProfile?.name || 'Guest'
}
},
methods: {
handleUserAuthentication() { // Cannot shorten
this.userAuthenticated = true
}
}
}
```
```javascript
// COMPOSITION API - Before minification
<script setup>
import { ref, computed } from 'vue'
const userAuthenticated = ref(false)
const currentUserProfile = ref(null)
const navigationMenuOpen = ref(false)
const displayUserName = computed(() =>
currentUserProfile.value?.name || 'Guest'
)
function handleUserAuthentication() {
userAuthenticated.value = true
}
</script>
// COMPOSITION API - After minification
<script setup>
import { ref as r, computed as c } from 'vue'
const a = r(false) // userAuthenticated -> a
const b = r(null) // currentUserProfile -> b
const d = r(false) // navigationMenuOpen -> d
const e = c(() => b.value?.name || 'Guest') // displayUserName -> e
function f() { a.value = true } // handleUserAuthentication -> f
</script>
```
## Runtime Performance
```javascript
// OPTIONS API - Every property access goes through proxy
export default {
methods: {
doSomething() {
// this.count triggers proxy get trap
// this.items.push triggers proxy get trap
console.log(this.count) // Proxy overhead
this.items.push(item) // Proxy overhead
}
}
}
// COMPOSITION API - Direct variable access
<script setup>
const count = ref(0)
const items = ref([])
function doSomething() {
// Direct variable access - no proxy indirection for the variable itself
console.log(count.value)
items.value.push(item)
}
</script>
```
## Dropping Options API for Pure Composition API Projects
```javascript
// vite.config.js - Only for projects using exclusively Composition API
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
define: {
// This drops Options API support from the bundle
__VUE_OPTIONS_API__: false
}
})
// WARNING: This will break any component (including from libraries)
// that uses Options API. Only use if you're certain all components
// use Composition API.
```
## When This Matters
The bundle size difference is typically:
- **Small components**: Negligible difference
- **Large applications**: 10-15% smaller with Composition API
- **With Options API flag disabled**: Additional 5-10% savings
Choose Composition API primarily for its code organization and logic reuse benefits. The bundle size improvement is a nice bonus, not the main reason to switch.
## Reference
- [Composition API FAQ - Smaller Production Bundle](https://vuejs.org/guide/extras/composition-api-faq.html#smaller-production-bundle-and-less-overhead)
- [Vue 3 Build Feature Flags](https://github.com/vuejs/core/tree/main/packages/vue#bundler-build-feature-flags)

View File

@@ -0,0 +1,213 @@
---
title: Organize Composition API Code by Logical Concern, Not Option Type
impact: MEDIUM
impactDescription: Poor code organization in Composition API leads to spaghetti code worse than Options API
type: best-practice
tags: [vue3, composition-api, code-organization, refactoring, maintainability]
---
# Organize Composition API Code by Logical Concern, Not Option Type
**Impact: MEDIUM** - The Composition API removes the "guard rails" of Options API that force code into data/methods/computed buckets. Without intentional organization, Composition API code can become more disorganized than Options API. Group related code together by feature or logical concern.
The key insight is that Composition API gives you flexibility - which requires discipline. Apply the same code organization principles you would use for any well-structured JavaScript code.
## Task Checklist
- [ ] Group related state, computed, and methods together by feature
- [ ] Extract related logic into composables when it grows
- [ ] Don't scatter related code throughout the script section
- [ ] Use comments or regions to delineate logical sections in larger components
- [ ] Consider splitting large components into smaller ones or composables
**Disorganized (Bad):**
```vue
<script setup>
// Scattered code - hard to understand what relates to what
import { ref, computed, onMounted, watch } from 'vue'
const searchQuery = ref('')
const items = ref([])
const selectedItem = ref(null)
const isModalOpen = ref(false)
const sortOrder = ref('asc')
const filterCategory = ref('all')
const isLoading = ref(false)
const error = ref(null)
const filteredItems = computed(() => {
return items.value.filter(i => i.category === filterCategory.value)
})
function openModal() { isModalOpen.value = true }
const sortedItems = computed(() => {
return [...filteredItems.value].sort(/* ... */)
})
function closeModal() { isModalOpen.value = false }
watch(searchQuery, async (query) => { /* fetch */ })
function selectItem(item) { selectedItem.value = item }
const searchResults = computed(() => {
return items.value.filter(i => i.name.includes(searchQuery.value))
})
onMounted(() => { fetchItems() })
async function fetchItems() { /* ... */ }
</script>
```
**Organized by Concern (Good):**
```vue
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
// ============================================
// DATA FETCHING & STATE
// ============================================
const items = ref([])
const isLoading = ref(false)
const error = ref(null)
async function fetchItems() {
isLoading.value = true
try {
items.value = await api.getItems()
} catch (e) {
error.value = e
} finally {
isLoading.value = false
}
}
onMounted(() => fetchItems())
// ============================================
// SEARCH
// ============================================
const searchQuery = ref('')
const searchResults = computed(() =>
items.value.filter(i =>
i.name.toLowerCase().includes(searchQuery.value.toLowerCase())
)
)
watch(searchQuery, async (query) => {
if (query.length > 2) {
await fetchItems({ search: query })
}
})
// ============================================
// FILTERING & SORTING
// ============================================
const filterCategory = ref('all')
const sortOrder = ref('asc')
const filteredItems = computed(() =>
searchResults.value.filter(i =>
filterCategory.value === 'all' || i.category === filterCategory.value
)
)
const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) =>
sortOrder.value === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name)
)
)
// ============================================
// SELECTION & MODAL
// ============================================
const selectedItem = ref(null)
const isModalOpen = ref(false)
function selectItem(item) {
selectedItem.value = item
openModal()
}
function openModal() { isModalOpen.value = true }
function closeModal() {
isModalOpen.value = false
selectedItem.value = null
}
</script>
```
**Best: Extract to Composables:**
```vue
<script setup>
import { useItems } from '@/composables/useItems'
import { useSearch } from '@/composables/useSearch'
import { useModal } from '@/composables/useModal'
// Each composable encapsulates a logical concern
const { items, isLoading, error, fetchItems } = useItems()
const { searchQuery, searchResults } = useSearch(items)
const {
selectedItem,
isOpen: isModalOpen,
open: openModal,
close: closeModal
} = useModal()
function selectItem(item) {
selectedItem.value = item
openModal()
}
</script>
// composables/useItems.js
export function useItems() {
const items = ref([])
const isLoading = ref(false)
const error = ref(null)
async function fetchItems(params = {}) {
isLoading.value = true
try {
items.value = await api.getItems(params)
} catch (e) {
error.value = e
} finally {
isLoading.value = false
}
}
onMounted(() => fetchItems())
return { items, isLoading, error, fetchItems }
}
```
## Signs Your Component Needs Refactoring
1. **Scrolling between related code** - If you're jumping around to understand one feature
2. **300+ lines in script setup** - Consider extracting composables
3. **Multiple unrelated features** - Each should be its own composable
4. **Similar patterns repeated** - Extract to shared composable
## When to Extract to Composables
```javascript
// Extract when:
// - Logic is reused across components
// - A feature is self-contained (search, pagination, form handling)
// - Component is getting too large (>200 lines)
// - You want to test logic in isolation
// Keep inline when:
// - Logic is simple and component-specific
// - Extracting would add more complexity than it removes
// - The component is already small and focused
```
## Reference
- [Composition API FAQ - More Flexible Code Organization](https://vuejs.org/guide/extras/composition-api-faq.html#more-flexible-code-organization)
- [Composables](https://vuejs.org/guide/reusability/composables.html)

View File

@@ -0,0 +1,208 @@
---
title: Use Composables Instead of Mixins for Logic Reuse
impact: HIGH
impactDescription: Mixins cause naming conflicts, unclear data origins, and inflexible logic - composables solve all these problems
type: best-practice
tags: [vue3, composition-api, composables, mixins, refactoring, code-reuse]
---
# Use Composables Instead of Mixins for Logic Reuse
**Impact: HIGH** - Mixins, the primary logic reuse mechanism in Options API, have fundamental flaws that make code hard to maintain. Composables (Composition API functions) solve all mixin drawbacks: unclear property origins, naming conflicts, and inability to parameterize.
The ability to create clean, reusable logic through composables is the primary advantage of the Composition API.
## Task Checklist
- [ ] Migrate existing mixins to composables when refactoring
- [ ] Never create new mixins - use composables instead
- [ ] Use explicit imports to make data origins clear
- [ ] Parameterize composables to make them flexible
- [ ] Prefix composables with "use" (useAuth, useFetch, useForm)
**Problems with Mixins:**
```javascript
// userMixin.js
export const userMixin = {
data() {
return {
user: null,
loading: false // Conflict waiting to happen!
}
},
methods: {
fetchUser() { /* ... */ }
}
}
// authMixin.js
export const authMixin = {
data() {
return {
token: null,
loading: false // NAME CONFLICT with userMixin!
}
},
methods: {
login() { /* ... */ }
}
}
// Component using mixins - PROBLEMATIC
export default {
mixins: [userMixin, authMixin],
mounted() {
// PROBLEM 1: Where does 'user' come from? Have to check mixins
console.log(this.user)
// PROBLEM 2: Which 'loading'? Last mixin wins, silently!
console.log(this.loading) // Is this user loading or auth loading?
// PROBLEM 3: Can't customize behavior per-component
this.fetchUser() // Always fetches the same way
}
}
```
**Composables Solution:**
```javascript
// composables/useUser.js
import { ref } from 'vue'
export function useUser(userId) { // Can accept parameters!
const user = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchUser() {
loading.value = true
try {
user.value = await api.getUser(userId)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return { user, loading, error, fetchUser }
}
// composables/useAuth.js
import { ref } from 'vue'
export function useAuth() {
const token = ref(null)
const loading = ref(false) // No conflict - it's scoped!
async function login(credentials) { /* ... */ }
function logout() { /* ... */ }
return { token, loading, login, logout }
}
// Component using composables - CLEAR AND FLEXIBLE
<script setup>
import { useUser } from '@/composables/useUser'
import { useAuth } from '@/composables/useAuth'
// SOLUTION 1: Clear where everything comes from
const { user, loading: userLoading, fetchUser } = useUser(123)
const { token, loading: authLoading, login } = useAuth()
// SOLUTION 2: Rename to avoid any conflicts
// userLoading vs authLoading - explicit!
// SOLUTION 3: Parameterize behavior
const adminUser = useUser(adminId)
const currentUser = useUser(currentUserId)
// Each has its own state!
onMounted(() => {
fetchUser() // Explicitly from useUser
})
</script>
```
## Migrating from Mixins
```javascript
// BEFORE: Mixin with options
export const formMixin = {
data() {
return { errors: {}, submitting: false }
},
methods: {
validate() { /* ... */ },
submit() { /* ... */ }
}
}
// AFTER: Composable with flexibility
export function useForm(initialValues, validationSchema) {
const values = ref({ ...initialValues })
const errors = ref({})
const submitting = ref(false)
const touched = ref({})
function validate() {
errors.value = validationSchema.validate(values.value)
return Object.keys(errors.value).length === 0
}
async function submit(onSubmit) {
if (!validate()) return
submitting.value = true
try {
await onSubmit(values.value)
} finally {
submitting.value = false
}
}
function reset() {
values.value = { ...initialValues }
errors.value = {}
touched.value = {}
}
return {
values,
errors,
submitting,
touched,
validate,
submit,
reset
}
}
// Usage - now parameterizable and explicit
const loginForm = useForm(
{ email: '', password: '' },
loginValidationSchema
)
const registerForm = useForm(
{ email: '', password: '', name: '' },
registerValidationSchema
)
```
## Composition Over Mixins Benefits
| Aspect | Mixins | Composables |
|--------|--------|-------------|
| Property origin | Unclear | Explicit import |
| Naming conflicts | Silent overwrites | Explicit rename |
| Parameters | Not possible | Fully supported |
| Type inference | Poor | Excellent |
| Reuse instances | One per component | Multiple allowed |
| Tree-shaking | Not possible | Fully supported |
## Reference
- [Composition API FAQ - Better Logic Reuse](https://vuejs.org/guide/extras/composition-api-faq.html#better-logic-reuse)
- [Composables](https://vuejs.org/guide/reusability/composables.html)
- [VueUse - Collection of Composables](https://vueuse.org/)

View File

@@ -0,0 +1,120 @@
---
title: Composition API Uses Mutable Reactivity, Not Functional Programming
impact: MEDIUM
impactDescription: Misunderstanding the paradigm leads to incorrect state management patterns
type: gotcha
tags: [vue3, composition-api, reactivity, functional-programming, paradigm]
---
# Composition API Uses Mutable Reactivity, Not Functional Programming
**Impact: MEDIUM** - Despite being function-based, the Composition API follows Vue's mutable, fine-grained reactivity paradigm—NOT functional programming principles. Treating it like a functional paradigm leads to incorrect patterns like unnecessary cloning, immutable-style updates, or avoiding mutation when mutation is the intended pattern.
Vue's Composition API leverages imported functions to organize code, but the underlying model is based on mutable reactive state that Vue tracks and responds to. This is fundamentally different from functional programming with immutability (like Redux reducers).
## Task Checklist
- [ ] Mutate reactive state directly - don't create new objects for every update
- [ ] Don't apply immutability patterns unnecessarily (spreading, Object.assign for updates)
- [ ] Understand that `ref()` and `reactive()` enable mutable state tracking
- [ ] Use Vue's reactivity as intended: direct mutation with automatic tracking
**Incorrect:**
```javascript
import { ref } from 'vue'
const todos = ref([])
// WRONG: Treating Vue like Redux/functional - unnecessary immutability
function addTodo(todo) {
// Creating a new array every time is wasteful in Vue
todos.value = [...todos.value, todo]
}
function updateTodo(id, updates) {
// Unnecessary spread - Vue tracks mutations directly
todos.value = todos.value.map(t =>
t.id === id ? { ...t, ...updates } : t
)
}
const user = ref({ name: 'John', age: 30 })
// WRONG: Creating new object for simple update
function updateName(newName) {
user.value = { ...user.value, name: newName }
}
```
**Correct:**
```javascript
import { ref, reactive } from 'vue'
const todos = ref([])
// CORRECT: Mutate directly - Vue tracks the change
function addTodo(todo) {
todos.value.push(todo) // Direct mutation is the Vue way
}
function updateTodo(id, updates) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
Object.assign(todo, updates) // Direct mutation
}
}
const user = ref({ name: 'John', age: 30 })
// CORRECT: Mutate the property directly
function updateName(newName) {
user.value.name = newName // Vue tracks this!
}
// Or with reactive():
const state = reactive({ name: 'John', age: 30 })
function updateNameReactive(newName) {
state.name = newName // Direct mutation, reactivity preserved
}
```
## When Immutability Patterns Make Sense
```javascript
// Immutability IS appropriate when:
// 1. Replacing the entire state (e.g., from API response)
const users = ref([])
async function fetchUsers() {
users.value = await api.getUsers() // Complete replacement is fine
}
// 2. When you need a snapshot for comparison
const previousState = { ...currentState } // For undo/redo
// 3. When passing data to external libraries expecting immutable data
const chartData = computed(() => [...rawData.value]) // Copy for chart lib
```
## The Vue Mental Model
```javascript
// Vue's reactivity is like a spreadsheet:
// - Cell A1 contains a value (ref)
// - Cell B1 has a formula referencing A1 (computed)
// - Change A1, and B1 automatically updates
const a1 = ref(10)
const b1 = computed(() => a1.value * 2)
// You CHANGE A1 (mutate), you don't create a new A1
a1.value = 20 // b1 automatically becomes 40
// This is fundamentally different from:
// state = reducer(state, action) // Functional/Redux pattern
```
## Reference
- [Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)
- [Reactivity Fundamentals](https://vuejs.org/guide/essentials/reactivity-fundamentals.html)

View File

@@ -0,0 +1,185 @@
---
title: Composition and Options API Can Coexist in Same Component
impact: LOW
impactDescription: Understanding coexistence helps gradual migration and library integration
type: best-practice
tags: [vue3, composition-api, options-api, migration, interoperability]
---
# Composition and Options API Can Coexist in Same Component
**Impact: LOW** - Vue 3 allows using both APIs in the same component via the `setup()` option. This is useful for gradual migration of existing Options API codebases or integrating Composition API libraries into Options API components.
However, this should be a transitional pattern. For new code, pick one API style and stick with it.
## Task Checklist
- [ ] Only mix APIs when migrating existing code or integrating libraries
- [ ] Use `setup()` option (not `<script setup>`) when mixing with Options API
- [ ] Return refs/reactive from setup to make them available to Options API code
- [ ] Avoid mixing long-term - plan to fully migrate eventually
- [ ] Understand that Options API `this` is NOT available in `setup()`
**Using Composition API in Options API Component:**
```javascript
import { ref, computed, onMounted } from 'vue'
import { useExternalLibrary } from 'some-composition-library'
export default {
// Options API parts
data() {
return {
legacyData: 'from options api'
}
},
computed: {
legacyComputed() {
// Can access both Options API data AND setup() returned values
return this.legacyData + ' - ' + this.newFeatureData
}
},
methods: {
legacyMethod() {
// Can call methods from both APIs
this.composableMethod()
}
},
// Composition API via setup()
setup() {
// Use composition library that doesn't have Options API equivalent
const { data: libraryData, doSomething } = useExternalLibrary()
// Create new reactive state with Composition API
const newFeatureData = ref('from composition api')
const newComputed = computed(() =>
newFeatureData.value.toUpperCase()
)
function composableMethod() {
newFeatureData.value = 'updated'
}
// IMPORTANT: Return values to make them available to Options API
return {
libraryData,
doSomething,
newFeatureData,
newComputed,
composableMethod
}
}
}
```
**Common Migration Pattern:**
```javascript
// Step 1: Original Options API component
export default {
data() {
return {
users: [],
loading: false
}
},
methods: {
async fetchUsers() {
this.loading = true
this.users = await api.getUsers()
this.loading = false
}
},
mounted() {
this.fetchUsers()
}
}
// Step 2: Extract logic to composable, use via setup()
import { useUsers } from '@/composables/useUsers'
export default {
data() {
return {
// Keep some Options API data during migration
selectedUserId: null
}
},
computed: {
selectedUser() {
// Mix Options API computed with Composition API data
return this.users.find(u => u.id === this.selectedUserId)
}
},
setup() {
// New logic in Composition API
const { users, loading, fetchUsers } = useUsers()
return { users, loading, fetchUsers }
},
mounted() {
this.fetchUsers() // Available from setup()
}
}
// Step 3: Fully migrate to <script setup>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useUsers } from '@/composables/useUsers'
const { users, loading, fetchUsers } = useUsers()
const selectedUserId = ref(null)
const selectedUser = computed(() =>
users.value.find(u => u.id === selectedUserId.value)
)
onMounted(() => fetchUsers())
</script>
```
**Important Limitations:**
```javascript
export default {
data() {
return { optionsData: 'hello' }
},
setup(props, context) {
// WRONG: 'this' is NOT available in setup()
console.log(this.optionsData) // undefined!
// CORRECT: Access props and context via parameters
console.log(props.someProp)
console.log(context.attrs)
console.log(context.emit)
// To access Options API data from setup,
// you generally can't - they're in separate scopes
// The Options API CAN access setup's returned values though
return { /* ... */ }
}
}
```
## When to Use This Pattern
- **Migrating large codebase**: Migrate piece by piece without rewriting everything
- **Integrating libraries**: Some libraries (like VueUse) are Composition API only
- **Team transition**: Let teams learn Composition API gradually
- **Options API components that need one composable**: Quick integration
## When NOT to Use This Pattern
- **New components**: Just use `<script setup>` from the start
- **Simple components**: Not worth the mental overhead
- **Long-term**: Plan to fully migrate; mixing adds complexity
## Reference
- [Composition API FAQ - Using Both APIs](https://vuejs.org/guide/extras/composition-api-faq.html#can-i-use-both-apis-in-the-same-component)
- [setup() option](https://vuejs.org/api/composition-api-setup.html)

View File

@@ -0,0 +1,156 @@
---
title: Vue Composition API Runs Once, Unlike React Hooks
impact: MEDIUM
impactDescription: Understanding this difference prevents over-engineering and React patterns that don't apply
type: gotcha
tags: [vue3, composition-api, react-hooks, setup, stale-closure]
---
# Vue Composition API Runs Once, Unlike React Hooks
**Impact: MEDIUM** - Vue's `setup()` or `<script setup>` executes only once per component instance, while React Hooks run on every render. Developers coming from React often apply patterns (dependency arrays, excessive memoization, useCallback) that are unnecessary and counterproductive in Vue.
Understanding this fundamental difference is crucial for writing idiomatic Vue code. Vue's approach eliminates entire categories of bugs (stale closures, exhaustive deps) that plague React applications.
## Task Checklist
- [ ] Don't implement "dependency arrays" - Vue tracks dependencies automatically
- [ ] Don't wrap functions in "useCallback" equivalents - not needed in Vue
- [ ] Don't use "useMemo" patterns - Vue's `computed()` handles this automatically
- [ ] Understand that closures in Vue don't go "stale" like in React
- [ ] Don't worry about "call order" - Vue composables can be conditional
**React Patterns to Avoid in Vue:**
```javascript
// These patterns are UNNECESSARY in Vue - they solve React-specific problems
// WRONG: Trying to implement dependency arrays (React pattern)
watch(
[dep1, dep2, dep3], // Vue tracks deps automatically in watchEffect
() => {
// ...
}
)
// Unless you specifically WANT to control which deps trigger the watcher,
// prefer watchEffect() which auto-tracks
// WRONG: Memoizing callbacks like useCallback
const memoizedHandler = computed(() => {
return () => doSomething(state.value)
})
// In Vue, just define the function normally - no memoization needed
// WRONG: Worrying about stale closures
function useData() {
const data = ref(null)
// In React, this could capture stale 'data' - NOT in Vue!
// Vue refs are always current
const handler = () => {
console.log(data.value) // Always gets current value
}
return { data, handler }
}
```
**Correct Vue Patterns:**
```javascript
import { ref, computed, watchEffect } from 'vue'
// CORRECT: Auto-dependency tracking with watchEffect
const query = ref('')
const filter = ref('all')
watchEffect(() => {
// Vue automatically detects that this depends on query and filter
// No dependency array needed!
fetchResults(query.value, filter.value)
})
// CORRECT: computed() handles memoization automatically
const expensiveResult = computed(() => {
// Only recalculates when dependencies actually change
return heavyComputation(data.value)
})
// CORRECT: Functions don't need memoization
function handleClick() {
count.value++
}
// Just use it directly - no useCallback wrapper needed
// <button @click="handleClick">
// CORRECT: Closures always access current values
const count = ref(0)
const message = ref('')
function logState() {
// This always logs CURRENT values, never stale ones
console.log(`Count: ${count.value}, Message: ${message.value}`)
}
setTimeout(() => {
logState() // Gets current values even if called later
}, 5000)
```
## Vue's Advantages Over React Hooks
```javascript
// 1. No stale closure problems
const count = ref(0)
onMounted(() => {
setInterval(() => {
// In React: would need useRef or deps array to avoid stale value
// In Vue: count.value is always current
console.log(count.value)
}, 1000)
})
// 2. Composables can be conditional
if (featureEnabled) {
const { data } = useSomeFeature() // This is FINE in Vue!
}
// In React: "Hooks cannot be conditional" - not a problem in Vue
// 3. No exhaustive-deps linting headaches
watchEffect(() => {
// Use any reactive values - Vue tracks them all automatically
// No ESLint rule yelling about missing dependencies
doSomething(a.value, b.value, c.value)
})
// 4. Child components don't need memoization by default
// Vue's reactivity system only updates what actually changed
// No need for React.memo() equivalents in most cases
```
## When Vue Patterns Differ
```javascript
// Setup runs once - so initialization happens once
<script setup>
import { ref, onMounted } from 'vue'
// This code runs ONCE when component is created
const data = ref(null)
console.log('Setup running') // Only logs once
onMounted(() => {
console.log('Mounted') // Only logs once
})
// If you need something to run on every reactive change,
// use watch or watchEffect
watchEffect(() => {
// This runs when dependencies change
console.log('Data changed:', data.value)
})
</script>
```
## Reference
- [Composition API FAQ - Relationship with React Hooks](https://vuejs.org/guide/extras/composition-api-faq.html#relationship-with-react-hooks)
- [Reactivity Fundamentals](https://vuejs.org/guide/essentials/reactivity-fundamentals.html)

View File

@@ -0,0 +1,148 @@
---
title: Avoid Mutating Methods on Arrays in Computed Properties
impact: HIGH
impactDescription: Array mutating methods in computed modify source data causing unexpected behavior
type: capability
tags: [vue3, computed, arrays, mutation, sort, reverse]
---
# Avoid Mutating Methods on Arrays in Computed Properties
**Impact: HIGH** - JavaScript array methods like `reverse()`, `sort()`, `splice()`, `push()`, `pop()`, `shift()`, and `unshift()` mutate the original array. Using them directly on reactive arrays inside computed properties will modify your source data, causing unexpected side effects and bugs.
## Task Checklist
- [ ] Always create a copy of arrays before using mutating methods
- [ ] Use spread operator `[...array]` or `slice()` to copy arrays
- [ ] Prefer non-mutating alternatives when available
- [ ] Be aware which array methods mutate vs return new arrays
**Incorrect:**
```vue
<script setup>
import { ref, computed } from 'vue'
const items = ref([3, 1, 4, 1, 5, 9, 2, 6])
const users = ref([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 }
])
// BAD: sort() mutates the original array!
const sortedItems = computed(() => {
return items.value.sort((a, b) => a - b)
})
// BAD: reverse() mutates the original array!
const reversedItems = computed(() => {
return items.value.reverse()
})
// BAD: Both arrays now point to the same mutated data
// items.value and sortedItems.value are the SAME array
// items.value and reversedItems.value are the SAME array
// BAD: Chained mutations
const sortedUsers = computed(() => {
return users.value.sort((a, b) => a.age - b.age)
})
</script>
<template>
<!-- Original array is corrupted! -->
<div>Original: {{ items }}</div>
<div>Sorted: {{ sortedItems }}</div>
</template>
```
**Correct:**
```vue
<script setup>
import { ref, computed } from 'vue'
const items = ref([3, 1, 4, 1, 5, 9, 2, 6])
const users = ref([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 }
])
// GOOD: Spread operator creates a copy first
const sortedItems = computed(() => {
return [...items.value].sort((a, b) => a - b)
})
// GOOD: slice() also creates a copy
const reversedItems = computed(() => {
return items.value.slice().reverse()
})
// GOOD: Copy before sorting objects
const sortedUsers = computed(() => {
return [...users.value].sort((a, b) => a.age - b.age)
})
// GOOD: Use toSorted() (ES2023) - non-mutating
const sortedItemsModern = computed(() => {
return items.value.toSorted((a, b) => a - b)
})
// GOOD: Use toReversed() (ES2023) - non-mutating
const reversedItemsModern = computed(() => {
return items.value.toReversed()
})
</script>
<template>
<!-- Original array stays intact -->
<div>Original: {{ items }}</div>
<div>Sorted: {{ sortedItems }}</div>
<div>Reversed: {{ reversedItems }}</div>
</template>
```
## Mutating vs Non-Mutating Array Methods
| Mutating (Avoid in Computed) | Non-Mutating (Safe) |
|------------------------------|---------------------|
| `sort()` | `toSorted()` (ES2023) |
| `reverse()` | `toReversed()` (ES2023) |
| `splice()` | `toSpliced()` (ES2023) |
| `push()` | `concat()` |
| `pop()` | `slice(0, -1)` |
| `shift()` | `slice(1)` |
| `unshift()` | `[item, ...array]` |
| `fill()` | `map()` with new values |
## ES2023 Non-Mutating Alternatives
Modern JavaScript (ES2023) provides non-mutating versions of common array methods:
```javascript
// These return NEW arrays, safe for computed properties
const sorted = array.toSorted((a, b) => a - b)
const reversed = array.toReversed()
const spliced = array.toSpliced(1, 2, 'new')
const withReplaced = array.with(0, 'newFirst')
```
## Deep Copy for Nested Arrays
For arrays of objects where you might mutate nested properties:
```javascript
const items = ref([{ name: 'A', values: [1, 2, 3] }])
// Shallow copy - nested arrays still shared
const copied = computed(() => [...items.value])
// Deep copy if you need to mutate nested structures
const deepCopied = computed(() => {
return JSON.parse(JSON.stringify(items.value))
// Or use structuredClone():
// return structuredClone(items.value)
})
```
## Reference
- [Vue.js Computed Properties - Avoid Mutating Computed Value](https://vuejs.org/guide/essentials/computed.html#avoid-mutating-computed-value)
- [MDN Array Methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)

View File

@@ -0,0 +1,147 @@
---
title: Ensure All Dependencies Are Accessed in Computed Properties
impact: HIGH
impactDescription: Conditional logic can prevent dependency tracking causing stale computed values
type: capability
tags: [vue3, computed, reactivity, dependency-tracking, gotcha]
---
# Ensure All Dependencies Are Accessed in Computed Properties
**Impact: HIGH** - Vue tracks computed property dependencies by monitoring which reactive properties are accessed during execution. If conditional logic prevents a property from being accessed on the first run, Vue won't track it as a dependency, causing the computed property to not update when that property changes.
This is a subtle but common source of bugs, especially with short-circuit evaluation (`&&`, `||`) and early returns.
## Task Checklist
- [ ] Access all reactive dependencies before any conditional logic
- [ ] Be cautious with short-circuit operators (`&&`, `||`) that may skip property access
- [ ] Store all dependencies in variables at the start of the computed getter
- [ ] Test computed properties with different initial states
**Incorrect:**
```vue
<script setup>
import { ref, computed } from 'vue'
const isEnabled = ref(false)
const data = ref('important data')
// BAD: If isEnabled is false initially, data.value is never accessed
// Vue won't track 'data' as a dependency!
const result = computed(() => {
if (!isEnabled.value) {
return 'disabled'
}
return data.value // This dependency may not be tracked
})
// BAD: Short-circuit prevents second access
const password = ref('')
const confirmPassword = ref('')
const isValid = computed(() => {
// If password is empty, confirmPassword is never accessed
return password.value && password.value === confirmPassword.value
})
// BAD: Early return prevents dependency access
const user = ref(null)
const permissions = ref(['read', 'write'])
const canEdit = computed(() => {
if (!user.value) {
return false // permissions.value never accessed when user is null
}
return permissions.value.includes('write')
})
</script>
```
**Correct:**
```vue
<script setup>
import { ref, computed } from 'vue'
const isEnabled = ref(false)
const data = ref('important data')
// GOOD: Access all dependencies first
const result = computed(() => {
const enabled = isEnabled.value
const currentData = data.value // Always accessed
if (!enabled) {
return 'disabled'
}
return currentData
})
// GOOD: Access both values before comparison
const password = ref('')
const confirmPassword = ref('')
const isValid = computed(() => {
const pwd = password.value
const confirm = confirmPassword.value // Always accessed
return pwd && pwd === confirm
})
// GOOD: Access all reactive sources upfront
const user = ref(null)
const permissions = ref(['read', 'write'])
const canEdit = computed(() => {
const currentUser = user.value
const currentPermissions = permissions.value // Always accessed
if (!currentUser) {
return false
}
return currentPermissions.includes('write')
})
</script>
```
## The Dependency Tracking Mechanism
Vue's reactivity system works by tracking which reactive properties are accessed when a computed property runs:
```javascript
// How Vue tracks dependencies (simplified):
// 1. Start tracking
// 2. Run the getter function
// 3. Record every .value or reactive property access
// 4. Stop tracking
const computed = computed(() => {
// Vue starts tracking here
if (conditionA.value) { // conditionA is tracked
return valueB.value // valueB is ONLY tracked if conditionA is true
}
return 'default' // If conditionA is false, valueB is NOT tracked!
})
```
## Pattern: Destructure All Dependencies First
```javascript
// GOOD PATTERN: Destructure/access everything at the top
const result = computed(() => {
// Access all potential dependencies
const { user, settings, items } = toRefs(store)
const userVal = user.value
const settingsVal = settings.value
const itemsVal = items.value
// Now use conditional logic safely
if (!userVal) return []
if (!settingsVal.enabled) return []
return itemsVal.filter(i => i.active)
})
```
## Reference
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
- [GitHub Discussion: Dependency collection gotcha with conditionals](https://github.com/vuejs/Discussion/issues/15)

View File

@@ -0,0 +1,159 @@
---
title: Computed Properties Cannot Accept Parameters
impact: MEDIUM
impactDescription: Attempting to pass arguments to computed properties fails or defeats caching
type: capability
tags: [vue3, computed, methods, parameters, common-mistake]
---
# Computed Properties Cannot Accept Parameters
**Impact: MEDIUM** - Computed properties are designed to derive values from reactive state without parameters. Attempting to pass arguments defeats the caching mechanism or causes errors. Use methods or computed properties that return functions instead.
## Task Checklist
- [ ] Use methods when you need to pass parameters
- [ ] Consider if the parameter can be reactive state instead
- [ ] If you must parameterize, understand that returning a function loses caching benefits
- [ ] Prefer method calls in templates for parameterized operations
**Incorrect:**
```vue
<template>
<!-- BAD: Computed properties don't accept parameters like this -->
<p>{{ filteredItems('active') }}</p>
<p>{{ formattedPrice(100, 'USD') }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const items = ref([/* ... */])
// BAD: This won't work as expected
// Computed is called once, not per parameter
const filteredItems = computed((status) => { // status will be undefined or previous value
return items.value.filter(i => i.status === status)
})
</script>
```
```vue
<script>
export default {
data() {
return { items: [/* ... */] }
},
computed: {
// BAD: Computed doesn't receive arguments
filteredItems(status) { // 'status' is actually 'this' or undefined
return this.items.filter(i => i.status === status)
}
}
}
</script>
```
**Correct:**
```vue
<template>
<!-- GOOD: Use method for parameterized operations -->
<p>{{ getFilteredItems('active') }}</p>
<p>{{ formatPrice(100, 'USD') }}</p>
<!-- GOOD: Or use computed with reactive filter state -->
<select v-model="statusFilter">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<p>{{ filteredItems }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const items = ref([/* ... */])
const statusFilter = ref('active')
// GOOD: Method for parameterized operations
function getFilteredItems(status) {
return items.value.filter(i => i.status === status)
}
function formatPrice(amount, currency) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency
}).format(amount)
}
// GOOD: Computed with reactive parameter
const filteredItems = computed(() => {
return items.value.filter(i => i.status === statusFilter.value)
})
</script>
```
## Workaround: Computed Returning a Function
If you need something computed-like with parameters, you can return a function. **However, this defeats the caching benefit:**
```vue
<template>
<p>{{ getItemsByStatus('active') }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const items = ref([/* ... */])
// This works but provides NO caching benefit
// The inner function runs every time it's called
const getItemsByStatus = computed(() => {
return (status) => items.value.filter(i => i.status === status)
})
// This is essentially equivalent to just using a method
// Only useful if you need to compose with other computed properties
</script>
```
## When to Use Each Approach
| Scenario | Approach | Caching |
|----------|----------|---------|
| Fixed filter based on reactive state | Computed | Yes |
| Dynamic filter passed as argument | Method | No |
| Filter options from user selection | Computed + reactive param | Yes |
| Formatting with variable parameters | Method | No |
| Composed derivation with argument | Computed returning function | Partial |
## Make Parameters Reactive
The best pattern is often to make the "parameter" a reactive value:
```vue
<script setup>
import { ref, computed } from 'vue'
const items = ref([/* ... */])
// Instead of passing 'status' as a parameter:
const currentStatus = ref('active')
// Make a computed that uses the reactive status
const filteredItems = computed(() => {
return items.value.filter(i => i.status === currentStatus.value)
})
// Change the filter by updating the ref
function filterByStatus(status) {
currentStatus.value = status
}
</script>
```
## Reference
- [Vue.js Computed Properties](https://vuejs.org/guide/essentials/computed.html)
- [Vue.js Methods](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#declaring-methods)

View File

@@ -0,0 +1,107 @@
---
title: Computed Property Getters Must Be Side-Effect Free
impact: HIGH
impactDescription: Side effects in computed getters break reactivity and cause unpredictable behavior
type: efficiency
tags: [vue3, computed, reactivity, side-effects, best-practices]
---
# Computed Property Getters Must Be Side-Effect Free
**Impact: HIGH** - Computed getter functions should only perform pure computation. Side effects in computed getters break Vue's reactivity model and cause bugs that are difficult to trace.
Computed properties are designed to declaratively describe how to derive a value from other reactive state. They are not meant to perform actions or modify state.
## Task Checklist
- [ ] Never mutate other reactive state inside a computed getter
- [ ] Never make async requests or API calls inside a computed getter
- [ ] Never perform DOM mutations inside a computed getter
- [ ] Use watchers for reacting to state changes with side effects
- [ ] Use event handlers for user-triggered actions
**Incorrect:**
```vue
<script setup>
import { ref, computed } from 'vue'
const items = ref([])
const count = ref(0)
const lastFetch = ref(null)
// BAD: Mutates other state
const doubledCount = computed(() => {
count.value++ // Side effect - modifying state!
return count.value * 2
})
// BAD: Makes async request
const userData = computed(async () => {
const response = await fetch('/api/user') // Side effect - API call!
return response.json()
})
// BAD: Modifies DOM
const highlightedItems = computed(() => {
document.title = `${items.value.length} items` // Side effect - DOM mutation!
return items.value.filter(i => i.highlighted)
})
// BAD: Writes to external state
const processedData = computed(() => {
lastFetch.value = new Date() // Side effect - modifying state!
return items.value.map(i => i.name)
})
</script>
```
**Correct:**
```vue
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const items = ref([])
const count = ref(0)
const userData = ref(null)
// GOOD: Pure computation only
const doubledCount = computed(() => {
return count.value * 2
})
// GOOD: Use lifecycle hook for initial fetch
onMounted(async () => {
const response = await fetch('/api/user')
userData.value = await response.json()
})
// GOOD: Pure filtering
const highlightedItems = computed(() => {
return items.value.filter(i => i.highlighted)
})
// GOOD: Use watcher for side effects
watch(items, (newItems) => {
document.title = `${newItems.length} items`
}, { immediate: true })
// Increment count through event handler, not computed
function increment() {
count.value++
}
</script>
```
## What Counts as a Side Effect
| Side Effect Type | Example | Alternative |
|-----------------|---------|-------------|
| State mutation | `otherRef.value = x` | Use watcher |
| API calls | `fetch()`, `axios()` | Use watcher or lifecycle hook |
| DOM manipulation | `document.title = x` | Use watcher |
| Console logging | `console.log()` | Remove or use watcher |
| Storage access | `localStorage.setItem()` | Use watcher |
| Timer setup | `setTimeout()` | Use lifecycle hook |
## Reference
- [Vue.js Computed Properties - Getters Should Be Side-Effect Free](https://vuejs.org/guide/essentials/computed.html#getters-should-be-side-effect-free)

View File

@@ -0,0 +1,121 @@
# Use Computed Properties for Complex Class Logic
## Rule
When class bindings involve multiple conditions or complex logic, extract them into computed properties rather than writing inline expressions in templates.
## Why This Matters
- Inline class expressions quickly become unreadable with multiple conditions
- Computed properties are cached and only re-evaluate when dependencies change
- Logic in computed properties is easier to test and debug
- Keeps templates focused on structure, not logic
## Bad Code
```vue
<template>
<!-- Hard to read, error-prone -->
<div :class="{
'btn': true,
'btn-primary': type === 'primary' && !disabled,
'btn-secondary': type === 'secondary' && !disabled,
'btn-disabled': disabled,
'btn-loading': isLoading,
'btn-large': size === 'large',
'btn-small': size === 'small'
}">
{{ label }}
</div>
<!-- Even worse: string concatenation -->
<div :class="'btn btn-' + type + (disabled ? ' btn-disabled' : '') + (isLoading ? ' btn-loading' : '')">
{{ label }}
</div>
</template>
```
## Good Code
```vue
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: { type: String, default: 'primary' },
size: { type: String, default: 'medium' },
disabled: Boolean,
isLoading: Boolean,
label: String
})
const buttonClasses = computed(() => ({
'btn': true,
[`btn-${props.type}`]: !props.disabled,
'btn-disabled': props.disabled,
'btn-loading': props.isLoading,
'btn-large': props.size === 'large',
'btn-small': props.size === 'small'
}))
</script>
<template>
<div :class="buttonClasses">
{{ label }}
</div>
</template>
```
## Style Bindings Too
The same principle applies to style bindings:
```vue
<script setup>
import { computed } from 'vue'
const props = defineProps({
color: String,
fontSize: Number,
isHighlighted: Boolean
})
const textStyles = computed(() => ({
color: props.color,
fontSize: `${props.fontSize}px`,
backgroundColor: props.isHighlighted ? 'yellow' : 'transparent',
fontWeight: props.isHighlighted ? 'bold' : 'normal'
}))
</script>
<template>
<span :style="textStyles">Styled text</span>
</template>
```
## Combining Static and Dynamic Classes
Use array syntax to combine static classes with computed dynamic classes:
```vue
<script setup>
import { computed } from 'vue'
const dynamicClasses = computed(() => ({
'is-active': isActive.value,
'is-disabled': isDisabled.value
}))
</script>
<template>
<!-- Static 'card' class + dynamic classes -->
<div :class="['card', dynamicClasses]">
Content
</div>
</template>
```
## References
- [Class and Style Bindings](https://vuejs.org/guide/essentials/class-and-style.html)
- [Computed Properties](https://vuejs.org/guide/essentials/computed.html)

View File

@@ -0,0 +1,160 @@
---
title: Never Mutate Computed Property Return Values
impact: HIGH
impactDescription: Mutating computed values causes silent failures and lost changes
type: capability
tags: [vue3, computed, reactivity, immutability, common-mistake]
---
# Never Mutate Computed Property Return Values
**Impact: HIGH** - The returned value from a computed property is derived state - a temporary snapshot. Mutating this value leads to bugs that are difficult to debug.
**Important:** Mutations DO persist while the computed cache remains valid, but are lost when recomputation occurs. The danger lies in unpredictable cache invalidation timing - any change to the computed's dependencies triggers recomputation, silently discarding your mutations. This makes bugs intermittent and hard to reproduce.
Every time the source state changes, a new snapshot is created. Mutating a snapshot is meaningless because it will be discarded on the next recalculation.
## Task Checklist
- [ ] Treat computed return values as read-only
- [ ] Update the source state instead of the computed value
- [ ] Use writable computed properties if bidirectional binding is needed
- [ ] Avoid array mutating methods (push, pop, splice, reverse, sort) on computed arrays
**Incorrect:**
```vue
<script setup>
import { ref, computed } from 'vue'
const books = ref(['Vue Guide', 'React Handbook'])
const publishedBooks = computed(() => {
return books.value.filter(book => book.includes('Guide'))
})
function addBook() {
// BAD: Mutating computed value - change will be lost!
publishedBooks.value.push('New Book')
}
// BAD: Mutating computed array
const sortedBooks = computed(() => books.value.filter(b => b))
function reverseBooks() {
// BAD: This mutates the computed snapshot
sortedBooks.value.reverse()
}
</script>
```
```vue
<script>
export default {
data() {
return {
author: {
name: 'John',
books: ['Book A', 'Book B']
}
}
},
computed: {
authorBooks() {
return this.author.books
}
},
methods: {
addBook() {
// BAD: Mutating computed value
this.authorBooks.push('New Book')
}
}
}
</script>
```
**Correct:**
```vue
<script setup>
import { ref, computed } from 'vue'
const books = ref(['Vue Guide', 'React Handbook'])
const publishedBooks = computed(() => {
return books.value.filter(book => book.includes('Guide'))
})
function addBook(bookName) {
// GOOD: Update the source state
books.value.push(bookName)
}
// GOOD: Create a copy before mutating for display
const sortedBooks = computed(() => {
return [...books.value].sort() // Spread to create copy before sort
})
const reversedBooks = computed(() => {
return [...books.value].reverse() // Spread to create copy before reverse
})
</script>
```
```vue
<script>
export default {
data() {
return {
author: {
name: 'John',
books: ['Book A', 'Book B']
}
}
},
computed: {
authorBooks() {
return this.author.books
}
},
methods: {
addBook(bookName) {
// GOOD: Update source state
this.author.books.push(bookName)
}
}
}
</script>
```
## Writable Computed for Bidirectional Binding
If you genuinely need to "set" a computed value, use a writable computed property:
```vue
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('John')
const lastName = ref('Doe')
// Writable computed with getter and setter
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(newValue) {
// Update source state based on the new value
const parts = newValue.split(' ')
firstName.value = parts[0] || ''
lastName.value = parts[1] || ''
}
})
// Now this is valid:
fullName.value = 'Jane Smith' // Updates firstName and lastName
</script>
```
## Reference
- [Vue.js Computed Properties - Avoid Mutating Computed Value](https://vuejs.org/guide/essentials/computed.html#avoid-mutating-computed-value)
- [Vue.js Computed Properties - Writable Computed](https://vuejs.org/guide/essentials/computed.html#writable-computed)

View File

@@ -0,0 +1,119 @@
---
title: Use Computed Properties for Cached Reactive Derivations
impact: MEDIUM
impactDescription: Methods recalculate on every render while computed properties cache results
type: efficiency
tags: [vue3, computed, methods, performance, caching]
---
# Use Computed Properties for Cached Reactive Derivations
**Impact: MEDIUM** - Computed properties are cached based on their reactive dependencies and only re-evaluate when dependencies change. Methods run on every component re-render, causing performance issues for expensive operations.
When you need to derive a value from reactive state, prefer computed properties over methods for automatic caching and optimized re-renders.
## Task Checklist
- [ ] Use computed properties for values derived from reactive state
- [ ] Use methods only when you need to pass parameters or don't want caching
- [ ] Never use computed for non-reactive values like `Date.now()`
- [ ] Consider performance impact of expensive operations in methods vs computed
**Incorrect:**
```vue
<template>
<!-- BAD: Method runs on every re-render -->
<p>{{ getFilteredItems() }}</p>
<p>{{ calculateTotal() }}</p>
<p>{{ getCurrentTime() }}</p>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([/* large array */])
const prices = ref([100, 200, 300])
// BAD: Expensive operation runs every render
function getFilteredItems() {
return items.value
.filter(item => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
}
// BAD: Calculation runs every render even if prices unchanged
function calculateTotal() {
return prices.value.reduce((sum, price) => sum + price, 0)
}
// This looks like a computed use case, but Date.now() is non-reactive
function getCurrentTime() {
return Date.now() // Will appear to work but won't update reactively
}
</script>
```
**Correct:**
```vue
<template>
<!-- GOOD: Computed only recalculates when items change -->
<p>{{ filteredItems }}</p>
<p>{{ total }}</p>
<!-- GOOD: Method for non-reactive current time -->
<p>{{ getCurrentTime() }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const items = ref([/* large array */])
const prices = ref([100, 200, 300])
// GOOD: Cached - only recalculates when items.value changes
const filteredItems = computed(() => {
return items.value
.filter(item => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
})
// GOOD: Cached - only recalculates when prices change
const total = computed(() => {
return prices.value.reduce((sum, price) => sum + price, 0)
})
// GOOD: Use method for non-reactive values
// (or use setInterval to update a ref)
function getCurrentTime() {
return Date.now()
}
</script>
```
## When to Use Each
| Scenario | Use Computed | Use Method |
|----------|--------------|------------|
| Derived from reactive state | Yes | No |
| Expensive calculation | Yes | No |
| Need to pass parameters | No | Yes |
| Non-reactive value (Date.now()) | No | Yes |
| Don't want caching | No | Yes |
| Triggered by user action | No | Yes |
## Non-Reactive Values Warning
Computed properties only track reactive dependencies. Non-reactive values like `Date.now()` will cause the computed to be evaluated once and never update:
```javascript
// BAD: Date.now() is not reactive - computed will never update
const now = computed(() => Date.now())
// GOOD: Use a ref with setInterval for live time
const now = ref(Date.now())
setInterval(() => {
now.value = Date.now()
}, 1000)
```
## Reference
- [Vue.js Computed Properties - Computed Caching vs Methods](https://vuejs.org/guide/essentials/computed.html#computed-caching-vs-methods)

View File

@@ -0,0 +1,134 @@
---
title: defineModel Creates Hidden Modifier Props - Avoid Naming Conflicts
impact: MEDIUM
impactDescription: defineModel automatically adds hidden *Modifiers props that can conflict with your prop names
type: gotcha
tags: [vue3, v-model, defineModel, modifiers, props, naming]
---
# defineModel Creates Hidden Modifier Props - Avoid Naming Conflicts
**Impact: MEDIUM** - When using `defineModel()`, Vue automatically creates hidden props with the suffix `Modifiers` for each model. For example, a model named `title` will create both a `title` prop AND a hidden `titleModifiers` prop. This can cause unexpected conflicts if you have other props ending in "Modifiers".
## Task Checklist
- [ ] Don't create props that end with "Modifiers" when using defineModel
- [ ] Be aware that each defineModel creates an associated *Modifiers prop
- [ ] When using multiple models, avoid names where one model could conflict with another's modifier prop
- [ ] Document custom modifiers to help consumers understand available options
**Problem - Hidden props created automatically:**
```vue
<script setup>
// This creates TWO props: modelValue and modelValueModifiers
const model = defineModel()
// This creates TWO props: title and titleModifiers
const title = defineModel('title')
// CONFLICT: This prop name collides with the hidden titleModifiers
const props = defineProps({
titleModifiers: Object // WRONG: Conflicts with defineModel's hidden prop
})
</script>
```
**Parent using modifiers:**
```vue
<template>
<!-- Vue passes modifiers via the hidden *Modifiers prop -->
<MyComponent v-model:title.capitalize.trim="text" />
<!-- Child receives titleModifiers = { capitalize: true, trim: true } -->
</template>
```
**Correct - Accessing modifiers in child:**
```vue
<script setup>
// Access modifiers via destructuring
const [title, titleModifiers] = defineModel('title', {
set(value) {
// Apply modifiers to the value
if (titleModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
if (titleModifiers.trim) {
value = value.trim()
}
return value
}
})
</script>
```
## Multiple Models and Potential Conflicts
```vue
<script setup>
// These are OK - no conflicts
const name = defineModel('name') // Creates: name, nameModifiers
const age = defineModel('age') // Creates: age, ageModifiers
// PROBLEM: If you had a model named 'model', it creates:
// - 'model' prop
// - 'modelModifiers' prop
// But 'modelValue' also creates 'modelValueModifiers'!
// The names are similar and can cause confusion
// AVOID: Don't name a model something that would conflict
const model = defineModel('model') // Creates 'model' and 'modelModifiers'
// This is confusing alongside the default modelValue/modelValueModifiers
</script>
```
**Best Practice - Clear, distinct model names:**
```vue
<script setup>
// Good: Clear, distinct names that won't conflict
const firstName = defineModel('firstName') // firstNameModifiers
const lastName = defineModel('lastName') // lastNameModifiers
const email = defineModel('email') // emailModifiers
// Avoid ambiguous names
// Bad: const value = defineModel('value') // valueModifiers - too generic
// Bad: const data = defineModel('data') // dataModifiers - too generic
</script>
```
## Documenting Custom Modifiers
When creating components with custom modifier support, document them clearly:
```vue
<script setup>
/**
* @prop {string} title - The title text
* @modifiers
* - capitalize: Capitalizes first letter
* - uppercase: Converts entire string to uppercase
* - trim: Removes leading/trailing whitespace
*/
const [title, modifiers] = defineModel('title', {
set(value: string) {
let result = value
if (modifiers.trim) result = result.trim()
if (modifiers.capitalize) {
result = result.charAt(0).toUpperCase() + result.slice(1)
}
if (modifiers.uppercase) {
result = result.toUpperCase()
}
return result
}
})
</script>
<template>
<input v-model="title" />
</template>
```
## Reference
- [Vue.js Component v-model - Modifiers](https://vuejs.org/guide/components/v-model.html#handling-v-model-modifiers)
- [Vue.js RFC - defineModel](https://github.com/vuejs/rfcs/discussions/503)

View File

@@ -0,0 +1,162 @@
---
title: defineModel Value Changes Apply After Next Tick
impact: MEDIUM
impactDescription: Reading model.value immediately after setting it returns the old value, not the new one
type: gotcha
tags: [vue3, v-model, defineModel, reactivity, timing, nextTick]
---
# defineModel Value Changes Apply After Next Tick
**Impact: MEDIUM** - When you assign a new value to a `defineModel()` ref, the change doesn't take effect immediately. Reading `model.value` right after assignment still returns the previous value. The new value is only available after Vue's next tick.
This can cause bugs when you need to perform operations with the updated value immediately after changing it.
## Task Checklist
- [ ] Don't read model.value immediately after setting it expecting the new value
- [ ] Use the value you assigned directly instead of re-reading from model
- [ ] Use nextTick() if you must read the updated value after assignment
- [ ] Consider batching related updates together
**Incorrect - Expecting immediate value update:**
```vue
<script setup>
const model = defineModel<string>()
function updateAndLog() {
model.value = 'new value'
// WRONG: This still logs the OLD value!
console.log(model.value) // Logs previous value, not 'new value'
// WRONG: Computation uses stale value
const length = model.value.length // Length of OLD value
// WRONG: Conditional check on stale value
if (model.value === 'new value') {
// This block may not execute!
doSomething()
}
}
</script>
```
**Correct - Use the value directly:**
```vue
<script setup>
const model = defineModel<string>()
function updateAndLog() {
const newValue = 'new value'
model.value = newValue
// CORRECT: Use the value you just assigned
console.log(newValue) // Logs 'new value'
// CORRECT: Compute from the known value
const length = newValue.length
// CORRECT: Check the known value
if (newValue === 'new value') {
doSomething()
}
}
</script>
```
**Alternative - Use nextTick for deferred operations:**
```vue
<script setup>
import { nextTick } from 'vue'
const model = defineModel<string>()
async function updateAndProcess() {
model.value = 'new value'
// Wait for Vue to apply the update
await nextTick()
// NOW model.value reflects the new value
console.log(model.value) // 'new value'
processUpdatedValue(model.value)
}
// Or using callback style
function updateWithCallback() {
model.value = 'new value'
nextTick(() => {
// Safe to read updated value here
console.log(model.value) // 'new value'
})
}
</script>
```
## Why This Happens
`defineModel` uses Vue's internal synchronization mechanism (`watchSyncEffect`) to sync with the parent. When you assign to `model.value`:
1. The local ref updates
2. An `update:modelValue` event is emitted to parent
3. Parent updates its ref
4. Vue syncs back to child in the next tick
During this cycle, the child's local value briefly differs from what's been committed.
## Pattern: Object Updates with Immediate Access
```vue
<script setup>
import { nextTick } from 'vue'
const model = defineModel<{ name: string; validated: boolean }>()
async function validateAndUpdate(newName: string) {
// Build the new object
const updated = {
...model.value,
name: newName,
validated: true
}
// Assign to model
model.value = updated
// Use 'updated' for immediate operations, not model.value
saveToServer(updated) // CORRECT: Use local reference
// If you need model.value specifically (e.g., for DOM sync):
await nextTick()
focusValidatedField() // Now safe to assume DOM updated
}
</script>
```
## Watch Callbacks Also See Updated Values
```vue
<script setup>
import { watch } from 'vue'
const model = defineModel<string>()
// Watch callback receives the new value
watch(model, (newValue, oldValue) => {
// 'newValue' is reliable here
console.log('Changed from', oldValue, 'to', newValue)
})
function update() {
model.value = 'new value'
// watch callback will fire with correct 'new value'
}
</script>
```
## Reference
- [Vue.js Reactivity - nextTick](https://vuejs.org/api/general.html#nexttick)
- [Vue.js Component v-model](https://vuejs.org/guide/components/v-model.html)
- [SIMPL Engineering: Vue defineModel Pitfalls](https://engineering.simpl.de/post/vue_definemodel/)

View File

@@ -0,0 +1,180 @@
---
title: Treat Directive Hook Arguments as Read-Only
impact: MEDIUM
impactDescription: Modifying directive arguments causes unpredictable behavior and breaks Vue's internal state
type: gotcha
tags: [vue3, directives, hooks, read-only, dataset]
---
# Treat Directive Hook Arguments as Read-Only
**Impact: MEDIUM** - Apart from `el`, you should treat all directive hook arguments (`binding`, `vnode`, `prevVnode`) as read-only and never modify them. Modifying these objects can cause unpredictable behavior and interfere with Vue's internal workings.
If you need to share information across hooks, use the element's `dataset` attribute or a WeakMap.
## Task Checklist
- [ ] Never mutate `binding`, `vnode`, or `prevVnode` arguments
- [ ] Use `el.dataset` to share primitive data between hooks
- [ ] Use a WeakMap for complex data that needs to persist across hooks
- [ ] Only modify `el` (the DOM element) directly
**Incorrect:**
```javascript
// WRONG: Mutating binding object
const vBadDirective = {
mounted(el, binding) {
// DON'T DO THIS - modifying binding
binding.value = 'modified' // WRONG!
binding.customData = 'stored' // WRONG!
binding.modifiers.custom = true // WRONG!
},
updated(el, binding) {
// These modifications may be lost or cause errors
console.log(binding.customData) // undefined or error
}
}
// WRONG: Mutating vnode
const vAnotherBadDirective = {
mounted(el, binding, vnode) {
// DON'T DO THIS
vnode.myData = 'stored' // WRONG!
vnode.props.modified = true // WRONG!
}
}
```
**Correct:**
```javascript
// CORRECT: Use el.dataset for simple data
const vWithDataset = {
mounted(el, binding) {
// Store data on the element's dataset
el.dataset.originalValue = binding.value
el.dataset.mountedAt = Date.now().toString()
},
updated(el, binding) {
// Access previously stored data
console.log('Original:', el.dataset.originalValue)
console.log('Current:', binding.value)
console.log('Mounted at:', el.dataset.mountedAt)
},
unmounted(el) {
// Clean up dataset if needed
delete el.dataset.originalValue
delete el.dataset.mountedAt
}
}
// CORRECT: Use WeakMap for complex data
const directiveState = new WeakMap()
const vWithWeakMap = {
mounted(el, binding) {
// Store complex state
directiveState.set(el, {
originalValue: binding.value,
config: binding.arg,
mountedAt: Date.now(),
callbacks: [],
observers: []
})
},
updated(el, binding) {
const state = directiveState.get(el)
if (state) {
console.log('Original:', state.originalValue)
console.log('Current:', binding.value)
// Can safely modify state object
state.updateCount = (state.updateCount || 0) + 1
}
},
unmounted(el) {
// WeakMap auto-cleans when element is garbage collected
// but explicit cleanup is good for observers/listeners
const state = directiveState.get(el)
if (state) {
state.observers.forEach(obs => obs.disconnect())
directiveState.delete(el)
}
}
}
```
## Using Element Properties
```javascript
// CORRECT: Use element properties with underscore prefix convention
const vTooltip = {
mounted(el, binding) {
// Store on element with underscore prefix to avoid conflicts
el._tooltipInstance = createTooltip(el, binding.value)
el._tooltipConfig = { ...binding.modifiers }
},
updated(el, binding) {
// Access and update stored instance
if (el._tooltipInstance) {
el._tooltipInstance.update(binding.value)
}
},
unmounted(el) {
// Clean up
if (el._tooltipInstance) {
el._tooltipInstance.destroy()
delete el._tooltipInstance
delete el._tooltipConfig
}
}
}
```
## What You CAN Modify
You are allowed to modify the `el` (DOM element) itself:
```javascript
const vHighlight = {
mounted(el, binding) {
// CORRECT: Modifying el directly is allowed
el.style.backgroundColor = binding.value
el.classList.add('highlighted')
el.setAttribute('data-highlighted', 'true')
el.textContent = 'Modified content'
},
updated(el, binding) {
// CORRECT: Update el when binding changes
el.style.backgroundColor = binding.value
}
}
```
## Binding Object Properties (Read-Only Reference)
The `binding` object contains:
- `value` - Current value passed to directive (read-only)
- `oldValue` - Previous value (only in beforeUpdate/updated) (read-only)
- `arg` - Argument passed (e.g., `v-dir:arg`) (read-only)
- `modifiers` - Object of modifiers (e.g., `v-dir.foo.bar`) (read-only)
- `instance` - Component instance (read-only)
- `dir` - Directive definition object (read-only)
```javascript
const vExample = {
mounted(el, binding) {
// READ these properties, don't modify them
console.log(binding.value) // Read: OK
console.log(binding.arg) // Read: OK
console.log(binding.modifiers) // Read: OK
console.log(binding.instance) // Read: OK
// Store what you need for later
el.dataset.directiveArg = binding.arg || ''
el.dataset.hasModifierFoo = binding.modifiers.foo ? 'true' : 'false'
}
}
```
## Reference
- [Vue.js Custom Directives - Hook Arguments](https://vuejs.org/guide/reusability/custom-directives#hook-arguments)
- [MDN - HTMLElement.dataset](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset)

View File

@@ -0,0 +1,149 @@
---
title: Avoid Using Custom Directives on Components
impact: HIGH
impactDescription: Custom directives on multi-root components are silently ignored, causing unexpected behavior
type: gotcha
tags: [vue3, directives, components, multi-root, best-practices]
---
# Avoid Using Custom Directives on Components
**Impact: HIGH** - Using custom directives on components is not recommended and can lead to unexpected behavior. When applied to a multi-root component, the directive will be ignored and a warning will be thrown. Unlike attributes, directives cannot be passed to a different element with `v-bind="$attrs"`.
Custom directives are designed for direct DOM manipulation on native HTML elements, not Vue components.
## Task Checklist
- [ ] Only apply custom directives to native HTML elements, not components
- [ ] If a component needs directive-like behavior, consider making it part of the component's API
- [ ] For components, use props and events instead of directives
- [ ] If you must use a directive on a component, ensure it has a single root element
**Incorrect:**
```vue
<template>
<!-- WRONG: Directive on a component - may be ignored -->
<MyComponent v-focus />
<!-- WRONG: Multi-root component - directive is ignored with warning -->
<MultiRootComponent v-highlight />
</template>
<script setup>
import MyComponent from './MyComponent.vue'
import MultiRootComponent from './MultiRootComponent.vue'
// MultiRootComponent.vue has:
// <template>
// <div>First root</div>
// <div>Second root</div>
// </template>
</script>
```
**Correct:**
```vue
<template>
<!-- CORRECT: Directive on native HTML element -->
<input v-focus />
<!-- CORRECT: Use props/events for component behavior -->
<MyComponent :should-focus="true" />
<!-- CORRECT: Wrap component in element if directive is needed -->
<div v-highlight>
<MyComponent />
</div>
</template>
<script setup>
import MyComponent from './MyComponent.vue'
</script>
```
## When a Directive on Component Works
Directives only work reliably on components with a **single root element**. The directive applies to the root node, similar to fallthrough attributes:
```vue
<!-- SingleRootComponent.vue -->
<template>
<div>I am the only root</div>
</template>
<!-- Parent.vue -->
<template>
<!-- This works because SingleRootComponent has one root -->
<SingleRootComponent v-my-directive />
</template>
```
However, this is still not recommended because:
1. It's fragile - refactoring to multi-root breaks the directive silently
2. It's unclear which element receives the directive
3. The component author may not expect external DOM manipulation
## Better Patterns
### Option 1: Component Prop
```vue
<!-- FocusableInput.vue -->
<template>
<input ref="inputRef" v-bind="$attrs" />
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
const props = defineProps({
autofocus: Boolean
})
const inputRef = ref(null)
onMounted(() => {
if (props.autofocus) {
inputRef.value?.focus()
}
})
</script>
<!-- Usage -->
<FocusableInput autofocus />
```
### Option 2: Exposed Method
```vue
<!-- FocusableInput.vue -->
<template>
<input ref="inputRef" />
</template>
<script setup>
import { ref } from 'vue'
const inputRef = ref(null)
const focus = () => inputRef.value?.focus()
defineExpose({ focus })
</script>
<!-- Parent.vue -->
<template>
<FocusableInput ref="myInput" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
const myInput = ref(null)
onMounted(() => {
myInput.value?.focus()
})
</script>
```
## Reference
- [Vue.js Custom Directives - Usage on Components](https://vuejs.org/guide/reusability/custom-directives#usage-on-components)

View File

@@ -0,0 +1,219 @@
---
title: Clean Up Side Effects in Directive unmounted Hook
impact: HIGH
impactDescription: Failing to clean up intervals, event listeners, and subscriptions in directives causes memory leaks
type: gotcha
tags: [vue3, directives, memory-leak, cleanup, unmounted, event-listeners]
---
# Clean Up Side Effects in Directive unmounted Hook
**Impact: HIGH** - A common and critical mistake when creating custom directives is forgetting to clean up intervals, event listeners, and other side effects in the `unmounted` hook. This causes memory leaks and ghost handlers that continue running after the element is removed from the DOM.
The key to avoiding such bugs is always implementing the `unmounted` hook to clean up any resources created in `mounted` or other lifecycle hooks.
## Task Checklist
- [ ] Always pair resource creation in `mounted` with cleanup in `unmounted`
- [ ] Store references to intervals, timeouts, and listeners for later cleanup
- [ ] Use `el.dataset` or WeakMap to share data between directive hooks
- [ ] Test that directives properly clean up when elements are removed (v-if toggling)
**Incorrect:**
```javascript
// WRONG: No cleanup - memory leak!
const vPoll = {
mounted(el, binding) {
// This interval runs forever, even after element is removed!
setInterval(() => {
console.log('polling...')
binding.value?.()
}, 1000)
}
}
// WRONG: Event listener persists after unmount
const vClickOutside = {
mounted(el, binding) {
document.addEventListener('click', (e) => {
if (!el.contains(e.target)) {
binding.value()
}
})
// No cleanup - listener stays attached to document!
}
}
```
**Correct:**
```javascript
// CORRECT: Store reference and clean up
const vPoll = {
mounted(el, binding) {
// Store interval ID on the element for later cleanup
el._pollInterval = setInterval(() => {
console.log('polling...')
binding.value?.()
}, 1000)
},
unmounted(el) {
// Clean up the interval
if (el._pollInterval) {
clearInterval(el._pollInterval)
delete el._pollInterval
}
}
}
// CORRECT: Named function for proper removal
const vClickOutside = {
mounted(el, binding) {
el._clickOutsideHandler = (e) => {
if (!el.contains(e.target)) {
binding.value()
}
}
document.addEventListener('click', el._clickOutsideHandler)
},
unmounted(el) {
if (el._clickOutsideHandler) {
document.removeEventListener('click', el._clickOutsideHandler)
delete el._clickOutsideHandler
}
}
}
```
## Using WeakMap for Cleaner State Management
```javascript
// BEST: Use WeakMap to avoid polluting element properties
const pollIntervals = new WeakMap()
const clickHandlers = new WeakMap()
const vPoll = {
mounted(el, binding) {
const intervalId = setInterval(() => {
binding.value?.()
}, binding.arg || 1000)
pollIntervals.set(el, intervalId)
},
unmounted(el) {
const intervalId = pollIntervals.get(el)
if (intervalId) {
clearInterval(intervalId)
pollIntervals.delete(el)
}
}
}
const vClickOutside = {
mounted(el, binding) {
const handler = (e) => {
if (!el.contains(e.target)) {
binding.value()
}
}
clickHandlers.set(el, handler)
document.addEventListener('click', handler)
},
unmounted(el) {
const handler = clickHandlers.get(el)
if (handler) {
document.removeEventListener('click', handler)
clickHandlers.delete(el)
}
}
}
```
## Complete Example with Multiple Resources
```javascript
const vAutoScroll = {
mounted(el, binding) {
const state = {
intervalId: null,
resizeObserver: null,
scrollHandler: null
}
// Set up polling
state.intervalId = setInterval(() => {
el.scrollTop = el.scrollHeight
}, binding.value?.interval || 100)
// Set up resize observer
state.resizeObserver = new ResizeObserver(() => {
el.scrollTop = el.scrollHeight
})
state.resizeObserver.observe(el)
// Set up scroll listener
state.scrollHandler = () => {
// Track user scroll
}
el.addEventListener('scroll', state.scrollHandler)
// Store all state for cleanup
el._autoScrollState = state
},
unmounted(el) {
const state = el._autoScrollState
if (!state) return
// Clean up everything
if (state.intervalId) {
clearInterval(state.intervalId)
}
if (state.resizeObserver) {
state.resizeObserver.disconnect()
}
if (state.scrollHandler) {
el.removeEventListener('scroll', state.scrollHandler)
}
delete el._autoScrollState
}
}
```
## Testing Directive Cleanup
```javascript
// Test that cleanup works properly
import { mount } from '@vue/test-utils'
import { ref, nextTick } from 'vue'
const vTrackInterval = {
mounted(el) {
el._interval = setInterval(() => {}, 100)
window.__activeIntervals = (window.__activeIntervals || 0) + 1
},
unmounted(el) {
clearInterval(el._interval)
window.__activeIntervals--
}
}
test('directive cleans up interval on unmount', async () => {
const show = ref(true)
const wrapper = mount({
template: `<div v-if="show" v-track-interval></div>`,
directives: { 'track-interval': vTrackInterval },
setup: () => ({ show })
})
expect(window.__activeIntervals).toBe(1)
show.value = false
await nextTick()
expect(window.__activeIntervals).toBe(0)
})
```
## Reference
- [Vue.js Custom Directives - Directive Hooks](https://vuejs.org/guide/reusability/custom-directives#directive-hooks)
- [Vue School - The Directive's unmounted Hook](https://vueschool.io/lessons/vue-3-the-directive-s-unmounted-hook)

View File

@@ -0,0 +1,189 @@
---
title: Use Function Shorthand for Simple Directives
impact: LOW
impactDescription: Function shorthand reduces boilerplate for directives with identical mounted/updated behavior
type: best-practice
tags: [vue3, directives, shorthand, code-style]
---
# Use Function Shorthand for Simple Directives
**Impact: LOW** - It's common for a custom directive to have the same behavior for `mounted` and `updated`, with no need for other hooks. In such cases, you can define the directive as a function instead of an object, reducing boilerplate and improving readability.
The function will be called for both `mounted` and `updated` lifecycle hooks.
## Task Checklist
- [ ] Use function shorthand when mounted and updated behavior is identical
- [ ] Use object syntax when you need beforeMount, beforeUpdate, or unmounted hooks
- [ ] Use object syntax when mounted and updated have different logic
**Verbose (when not needed):**
```javascript
// VERBOSE: Full object when behavior is identical
const vColor = {
mounted(el, binding) {
el.style.color = binding.value
},
updated(el, binding) {
el.style.color = binding.value // Same as mounted
}
}
const vHighlight = {
mounted(el, binding) {
el.style.backgroundColor = binding.value || 'yellow'
},
updated(el, binding) {
el.style.backgroundColor = binding.value || 'yellow' // Duplicated
}
}
// Global registration - verbose
app.directive('pin', {
mounted(el, binding) {
el.style.position = 'fixed'
el.style.top = binding.value + 'px'
},
updated(el, binding) {
el.style.position = 'fixed'
el.style.top = binding.value + 'px'
}
})
```
**Concise (function shorthand):**
```javascript
// CONCISE: Function shorthand
const vColor = (el, binding) => {
el.style.color = binding.value
}
const vHighlight = (el, binding) => {
el.style.backgroundColor = binding.value || 'yellow'
}
// Global registration - concise
app.directive('pin', (el, binding) => {
el.style.position = 'fixed'
el.style.top = binding.value + 'px'
})
```
## With script setup
```vue
<script setup>
// Function shorthand for local directives
const vFocus = (el) => {
el.focus()
}
const vColor = (el, binding) => {
el.style.color = binding.value
}
const vPin = (el, binding) => {
el.style.position = binding.modifiers.absolute ? 'absolute' : 'fixed'
const position = binding.arg || 'top'
el.style[position] = binding.value + 'px'
}
</script>
<template>
<input v-focus />
<p v-color="'red'">Colored text</p>
<div v-pin:left.absolute="100">Positioned element</div>
</template>
```
## When to Use Object Syntax
Use the full object syntax when:
### 1. You Need Cleanup (unmounted hook)
```javascript
// Need object syntax for cleanup
const vClickOutside = {
mounted(el, binding) {
el._handler = (e) => {
if (!el.contains(e.target)) binding.value(e)
}
document.addEventListener('click', el._handler)
},
unmounted(el) {
document.removeEventListener('click', el._handler)
}
}
```
### 2. Different Logic for mounted vs updated
```javascript
// Need object syntax for different behavior
const vLazyLoad = {
mounted(el, binding) {
// Initial setup - create observer
el._observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
el.src = binding.value
el._observer.disconnect()
}
})
el._observer.observe(el)
},
updated(el, binding, vnode, prevVnode) {
// Only update if value actually changed
if (binding.value !== binding.oldValue) {
el.src = binding.value
}
},
unmounted(el) {
el._observer?.disconnect()
}
}
```
### 3. You Need beforeMount or beforeUpdate
```javascript
// Need object syntax for early lifecycle hooks
const vAnimate = {
beforeMount(el) {
el.style.opacity = '0'
},
mounted(el) {
requestAnimationFrame(() => {
el.style.transition = 'opacity 0.3s'
el.style.opacity = '1'
})
},
beforeUpdate(el) {
el.style.opacity = '0.5'
},
updated(el) {
el.style.opacity = '1'
}
}
```
## Object Literal Values with Function Shorthand
Function shorthand works well with object literal values:
```javascript
const vDemo = (el, binding) => {
console.log(binding.value.color) // => "white"
console.log(binding.value.text) // => "hello!"
el.style.color = binding.value.color
el.textContent = binding.value.text
}
```
```vue
<template>
<div v-demo="{ color: 'white', text: 'hello!' }"></div>
</template>
```
## Reference
- [Vue.js Custom Directives - Function Shorthand](https://vuejs.org/guide/reusability/custom-directives#function-shorthand)

View File

@@ -0,0 +1,192 @@
---
title: Use v-prefix Naming Convention for Local Directives
impact: LOW
impactDescription: Proper naming enables automatic directive recognition in script setup
type: best-practice
tags: [vue3, directives, naming, script-setup, conventions]
---
# Use v-prefix Naming Convention for Local Directives
**Impact: LOW** - In `<script setup>`, any camelCase variable starting with the `v` prefix can automatically be used as a custom directive. This convention enables seamless local directive registration without explicit configuration.
Following this naming pattern ensures Vue correctly recognizes and registers your local directives.
## Task Checklist
- [ ] Name local directive variables with `v` prefix in camelCase (e.g., `vFocus`, `vHighlight`)
- [ ] Use the directive in templates without the `v` prefix lowercase (e.g., `v-focus`, `v-highlight`)
- [ ] For multi-word directives, use camelCase in script and kebab-case in template
**Incorrect:**
```vue
<script setup>
// WRONG: No v prefix - won't be recognized as directive
const focus = {
mounted: (el) => el.focus()
}
// WRONG: Wrong casing
const VFocus = {
mounted: (el) => el.focus()
}
// WRONG: Kebab-case in script
const 'v-focus' = { // Syntax error
mounted: (el) => el.focus()
}
</script>
<template>
<!-- These won't work -->
<input focus />
<input v-Focus />
</template>
```
**Correct:**
```vue
<script setup>
// CORRECT: v prefix with camelCase
const vFocus = {
mounted: (el) => el.focus()
}
const vHighlight = {
mounted: (el) => {
el.classList.add('is-highlight')
}
}
// CORRECT: Multi-word directive
const vClickOutside = {
mounted(el, binding) {
el._handler = (e) => {
if (!el.contains(e.target)) binding.value(e)
}
document.addEventListener('click', el._handler)
},
unmounted(el) {
document.removeEventListener('click', el._handler)
}
}
// CORRECT: Function shorthand with v prefix
const vColor = (el, binding) => {
el.style.color = binding.value
}
</script>
<template>
<!-- Use kebab-case in template -->
<input v-focus />
<p v-highlight>Highlighted text</p>
<div v-click-outside="closeMenu">Dropdown</div>
<span v-color="'red'">Colored text</span>
</template>
```
## Template Casing Rules
In templates, directives should use kebab-case:
```vue
<script setup>
const vMyLongDirectiveName = (el) => { /* ... */ }
const vAutoFocusInput = (el) => el.focus()
const vLazyLoadImage = { /* ... */ }
</script>
<template>
<!-- camelCase in script -> kebab-case in template -->
<div v-my-long-directive-name></div>
<input v-auto-focus-input />
<img v-lazy-load-image />
</template>
```
## Options API Registration
Without `<script setup>`, directives need explicit registration:
```javascript
// Local registration with Options API
export default {
directives: {
// Key is directive name (without v- prefix)
focus: {
mounted: (el) => el.focus()
},
highlight: {
mounted: (el) => el.classList.add('is-highlight')
},
// Multi-word uses camelCase key
clickOutside: {
mounted(el, binding) { /* ... */ },
unmounted(el) { /* ... */ }
}
}
}
```
## Global Registration
For global directives, register on the app instance:
```javascript
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// Global directive - name without v- prefix
app.directive('focus', {
mounted: (el) => el.focus()
})
// Multi-word directive
app.directive('click-outside', {
mounted(el, binding) { /* ... */ },
unmounted(el) { /* ... */ }
})
// Function shorthand
app.directive('color', (el, binding) => {
el.style.color = binding.value
})
app.mount('#app')
```
## Importing Directives
When importing directives, rename to add v prefix:
```javascript
// directives/focus.js
export const focus = {
mounted: (el) => el.focus()
}
// In component
<script setup>
import { focus as vFocus } from '@/directives/focus'
// Now usable as v-focus in template
</script>
// Or export with v prefix already
// directives/focus.js
export const vFocus = {
mounted: (el) => el.focus()
}
// In component
<script setup>
import { vFocus } from '@/directives/focus'
// Directly usable as v-focus
</script>
```
## Reference
- [Vue.js Custom Directives - Introduction](https://vuejs.org/guide/reusability/custom-directives#introduction)

View File

@@ -0,0 +1,220 @@
---
title: Prefer Built-in Directives Over Custom Directives
impact: MEDIUM
impactDescription: Custom directives are less efficient than built-in directives and not SSR-friendly
type: best-practice
tags: [vue3, directives, performance, ssr, best-practices]
---
# Prefer Built-in Directives Over Custom Directives
**Impact: MEDIUM** - Custom directives should only be used when the desired functionality can only be achieved via direct DOM manipulation. Declarative templating with built-in directives such as `v-bind`, `v-show`, `v-if`, and `v-on` is recommended when possible because they are more efficient and server-rendering friendly.
Before creating a custom directive, consider if the same result can be achieved with Vue's built-in reactivity and templating features.
## Task Checklist
- [ ] Before creating a custom directive, check if built-in directives can solve the problem
- [ ] Consider if a composable function would be more appropriate
- [ ] For SSR applications, evaluate if the directive will work on the server
- [ ] Only use custom directives for low-level DOM manipulation that can't be done declaratively
**Incorrect:**
```vue
<template>
<!-- WRONG: Custom directive for something v-show does -->
<div v-visibility="isVisible">Content</div>
<!-- WRONG: Custom directive for class binding -->
<div v-add-class="{ active: isActive }">Content</div>
<!-- WRONG: Custom directive for style binding -->
<div v-set-color="textColor">Content</div>
</template>
<script setup>
import { ref } from 'vue'
const isVisible = ref(true)
const isActive = ref(false)
const textColor = ref('blue')
// Unnecessary custom directives
const vVisibility = {
mounted(el, binding) {
el.style.display = binding.value ? '' : 'none'
},
updated(el, binding) {
el.style.display = binding.value ? '' : 'none'
}
}
const vAddClass = {
mounted(el, binding) {
Object.entries(binding.value).forEach(([cls, active]) => {
el.classList.toggle(cls, active)
})
},
updated(el, binding) {
Object.entries(binding.value).forEach(([cls, active]) => {
el.classList.toggle(cls, active)
})
}
}
const vSetColor = (el, binding) => {
el.style.color = binding.value
}
</script>
```
**Correct:**
```vue
<template>
<!-- CORRECT: Use built-in v-show -->
<div v-show="isVisible">Content</div>
<!-- CORRECT: Use built-in class binding -->
<div :class="{ active: isActive }">Content</div>
<!-- CORRECT: Use built-in style binding -->
<div :style="{ color: textColor }">Content</div>
</template>
<script setup>
import { ref } from 'vue'
const isVisible = ref(true)
const isActive = ref(false)
const textColor = ref('blue')
// No custom directives needed!
</script>
```
## When Custom Directives ARE Appropriate
Custom directives are appropriate when you need:
### 1. Direct DOM API Access
```javascript
// GOOD: Focus management requires DOM API
const vFocus = {
mounted(el) {
el.focus()
}
}
// Usage: Works on dynamic insertion, not just page load
// <input v-focus />
```
### 2. Third-Party Library Integration
```javascript
// GOOD: Integrating with external libraries
const vTippy = {
mounted(el, binding) {
el._tippy = tippy(el, {
content: binding.value,
...binding.modifiers
})
},
updated(el, binding) {
el._tippy?.setContent(binding.value)
},
unmounted(el) {
el._tippy?.destroy()
}
}
```
### 3. Event Handling Outside Vue's Scope
```javascript
// GOOD: Global event that Vue doesn't provide
const vClickOutside = {
mounted(el, binding) {
el._clickOutside = (e) => {
if (!el.contains(e.target)) {
binding.value(e)
}
}
document.addEventListener('click', el._clickOutside)
},
unmounted(el) {
document.removeEventListener('click', el._clickOutside)
}
}
```
### 4. Intersection/Mutation/Resize Observers
```javascript
// GOOD: IntersectionObserver requires DOM API
const vLazyLoad = {
mounted(el, binding) {
el._observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value
el._observer.disconnect()
}
})
el._observer.observe(el)
},
unmounted(el) {
el._observer?.disconnect()
}
}
```
## Consider Composables Instead
For complex logic, a composable might be better than a directive:
```javascript
// Composable approach - more flexible and testable
import { ref, onMounted, onUnmounted } from 'vue'
export function useClickOutside(elementRef, callback) {
const handler = (e) => {
if (elementRef.value && !elementRef.value.contains(e.target)) {
callback(e)
}
}
onMounted(() => document.addEventListener('click', handler))
onUnmounted(() => document.removeEventListener('click', handler))
}
// Usage in component
const dropdownRef = ref(null)
useClickOutside(dropdownRef, () => {
isOpen.value = false
})
```
## SSR Considerations
Custom directives don't run on the server, which can cause hydration issues:
```javascript
// PROBLEM: This directive modifies DOM, causing hydration mismatch
const vHydrationProblem = {
mounted(el) {
el.textContent = 'Client-side only text'
}
}
// SOLUTION: Use built-in directives or ensure server/client match
// Or handle hydration explicitly:
const vSafeForSSR = {
mounted(el, binding) {
// Only add behavior, don't modify content
el.addEventListener('click', binding.value)
},
unmounted(el, binding) {
el.removeEventListener('click', binding.value)
}
}
```
## Reference
- [Vue.js Custom Directives - Introduction](https://vuejs.org/guide/reusability/custom-directives#introduction)
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)

View File

@@ -0,0 +1,230 @@
---
title: Know When to Use Directives vs Components
impact: MEDIUM
impactDescription: Using directives when components are more appropriate leads to harder maintenance and testing
type: best-practice
tags: [vue3, directives, components, architecture, best-practices]
---
# Know When to Use Directives vs Components
**Impact: MEDIUM** - Accessing the component instance from within a custom directive is often a sign that the directive should rather be a component itself. Directives are designed for low-level DOM manipulation, while components are better for encapsulating behavior that involves state, reactivity, or complex logic.
Choosing the wrong abstraction leads to code that's harder to maintain, test, and reuse.
## Task Checklist
- [ ] Use directives for simple, stateless DOM manipulations
- [ ] Use components when you need encapsulated state or complex logic
- [ ] If accessing `binding.instance` frequently, consider using a component instead
- [ ] If the behavior needs its own template, use a component
- [ ] Consider composables for stateful logic that doesn't need a template
## Decision Matrix
| Requirement | Use Directive | Use Component | Use Composable |
|-------------|--------------|---------------|----------------|
| DOM manipulation only | Yes | - | - |
| Needs own template | - | Yes | - |
| Encapsulated state | - | Yes | Maybe |
| Reusable behavior | Yes | Yes | Yes |
| Access to parent instance | Avoid | - | Yes |
| SSR support needed | Avoid | Yes | Yes |
| Third-party lib integration | Yes | - | Maybe |
| Complex reactive logic | - | Yes | Yes |
## Directive-Appropriate Use Cases
```javascript
// GOOD: Simple DOM manipulation
const vFocus = {
mounted: (el) => el.focus()
}
// GOOD: Third-party library integration
const vTippy = {
mounted(el, binding) {
el._tippy = tippy(el, binding.value)
},
updated(el, binding) {
el._tippy?.setProps(binding.value)
},
unmounted(el) {
el._tippy?.destroy()
}
}
// GOOD: Event handling that Vue doesn't provide
const vClickOutside = {
mounted(el, binding) {
el._handler = (e) => {
if (!el.contains(e.target)) binding.value(e)
}
document.addEventListener('click', el._handler)
},
unmounted(el) {
document.removeEventListener('click', el._handler)
}
}
// GOOD: Intersection Observer
const vLazyLoad = {
mounted(el, binding) {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.src = binding.value
observer.disconnect()
}
})
observer.observe(el)
el._observer = observer
},
unmounted(el) {
el._observer?.disconnect()
}
}
```
## Component-Appropriate Use Cases
```vue
<!-- GOOD: Component with template and state -->
<!-- Tooltip.vue -->
<template>
<div class="tooltip-wrapper" @mouseenter="show" @mouseleave="hide">
<slot></slot>
<Transition name="fade">
<div v-if="isVisible" class="tooltip-content">
{{ content }}
</div>
</Transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
content: String
})
const isVisible = ref(false)
const show = () => isVisible.value = true
const hide = () => isVisible.value = false
</script>
```
```vue
<!-- GOOD: Component with complex logic -->
<!-- InfiniteScroll.vue -->
<template>
<div ref="container">
<slot></slot>
<div v-if="loading" class="loading-indicator">
<slot name="loading">Loading...</slot>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps({
threshold: { type: Number, default: 100 }
})
const emit = defineEmits(['load-more'])
const container = ref(null)
const loading = ref(false)
// Complex scroll logic with state management
const handleScroll = () => {
if (loading.value) return
const { scrollHeight, scrollTop, clientHeight } = container.value
if (scrollHeight - scrollTop - clientHeight < props.threshold) {
loading.value = true
emit('load-more', () => { loading.value = false })
}
}
onMounted(() => container.value?.addEventListener('scroll', handleScroll))
onUnmounted(() => container.value?.removeEventListener('scroll', handleScroll))
</script>
```
## Composable-Appropriate Use Cases
```javascript
// GOOD: Reusable stateful logic without template
// useClickOutside.js
import { onMounted, onUnmounted, ref } from 'vue'
export function useClickOutside(elementRef, callback) {
const isClickedOutside = ref(false)
const handler = (e) => {
if (elementRef.value && !elementRef.value.contains(e.target)) {
isClickedOutside.value = true
callback?.(e)
}
}
onMounted(() => document.addEventListener('click', handler))
onUnmounted(() => document.removeEventListener('click', handler))
return { isClickedOutside }
}
// Usage in component
const dropdownRef = ref(null)
const { isClickedOutside } = useClickOutside(dropdownRef, () => {
isOpen.value = false
})
```
## Anti-Pattern: Directive Accessing Instance Too Much
```javascript
// ANTI-PATTERN: Directive relying heavily on component instance
const vBadPattern = {
mounted(el, binding) {
// Accessing instance too much = should be a component
const instance = binding.instance
instance.someMethod()
instance.someProperty = 'value'
instance.$watch('someProp', (val) => {
el.textContent = val
})
}
}
// BETTER: Use a component or composable
// Component version
<template>
<div>{{ someProp }}</div>
</template>
<script setup>
const props = defineProps(['someProp'])
</script>
```
## When Instance Access is Acceptable
```javascript
// OK: Minimal instance access for specific needs
const vPermission = {
mounted(el, binding) {
// Checking a global permission - acceptable
const userPermissions = binding.instance.$store?.state.user.permissions
if (!userPermissions?.includes(binding.value)) {
el.style.display = 'none'
}
}
}
```
## Reference
- [Vue.js Custom Directives](https://vuejs.org/guide/reusability/custom-directives)
- [Vue.js Composables](https://vuejs.org/guide/reusability/composables.html)
- [Vue.js Components Basics](https://vuejs.org/guide/essentials/component-basics.html)

View File

@@ -0,0 +1,210 @@
---
title: Vue 3 Directive Hooks Renamed from Vue 2
impact: HIGH
impactDescription: Using Vue 2 hook names in Vue 3 causes directives to silently fail
type: gotcha
tags: [vue3, vue2, migration, directives, hooks, breaking-change]
---
# Vue 3 Directive Hooks Renamed from Vue 2
**Impact: HIGH** - Vue 3 renamed all custom directive lifecycle hooks to align with component lifecycle hooks. Using Vue 2 hook names will cause your directives to silently fail since the hooks won't be called. Additionally, the `update` hook was removed entirely.
This is a breaking change that requires updating all custom directives when migrating from Vue 2 to Vue 3.
## Task Checklist
- [ ] Rename `bind` to `beforeMount`
- [ ] Rename `inserted` to `mounted`
- [ ] Replace `update` with `beforeUpdate` or `updated` (update was removed)
- [ ] Rename `componentUpdated` to `updated`
- [ ] Rename `unbind` to `unmounted`
- [ ] Add `beforeUpdate` if you need the old `update` behavior
## Hook Name Mapping
| Vue 2 | Vue 3 |
|-----------------|----------------|
| `bind` | `beforeMount` |
| `inserted` | `mounted` |
| `update` | **removed** |
| `componentUpdated` | `updated` |
| `unbind` | `unmounted` |
| (none) | `created` |
| (none) | `beforeUpdate` |
| (none) | `beforeUnmount`|
**Vue 2 (old):**
```javascript
// Vue 2 directive - WILL NOT WORK IN VUE 3
Vue.directive('demo', {
bind(el, binding, vnode) {
// Called when directive is first bound to element
},
inserted(el, binding, vnode) {
// Called when element is inserted into parent
},
update(el, binding, vnode, oldVnode) {
// Called on every VNode update (REMOVED in Vue 3)
},
componentUpdated(el, binding, vnode, oldVnode) {
// Called after component and children update
},
unbind(el, binding, vnode) {
// Called when directive is unbound from element
}
})
```
**Vue 3 (new):**
```javascript
// Vue 3 directive - Correct hook names
app.directive('demo', {
created(el, binding, vnode) {
// NEW: called before element's attributes or event listeners are applied
},
beforeMount(el, binding, vnode) {
// Was: bind
},
mounted(el, binding, vnode) {
// Was: inserted
},
beforeUpdate(el, binding, vnode, prevVnode) {
// NEW: called before the element itself is updated
},
updated(el, binding, vnode, prevVnode) {
// Was: componentUpdated
// Note: 'update' was removed - use this or beforeUpdate instead
},
beforeUnmount(el, binding, vnode) {
// NEW: called before element is unmounted
},
unmounted(el, binding, vnode) {
// Was: unbind
}
})
```
## Migration Examples
### Simple Focus Directive
```javascript
// Vue 2
Vue.directive('focus', {
inserted(el) {
el.focus()
}
})
// Vue 3
app.directive('focus', {
mounted(el) {
el.focus()
}
})
```
### Directive with Cleanup
```javascript
// Vue 2
Vue.directive('click-outside', {
bind(el, binding) {
el._handler = (e) => {
if (!el.contains(e.target)) binding.value(e)
}
document.addEventListener('click', el._handler)
},
unbind(el) {
document.removeEventListener('click', el._handler)
}
})
// Vue 3
app.directive('click-outside', {
beforeMount(el, binding) { // or mounted
el._handler = (e) => {
if (!el.contains(e.target)) binding.value(e)
}
document.addEventListener('click', el._handler)
},
unmounted(el) {
document.removeEventListener('click', el._handler)
}
})
```
### Directive with Updates
```javascript
// Vue 2 - using update hook
Vue.directive('color', {
bind(el, binding) {
el.style.color = binding.value
},
update(el, binding) {
// Called on every VNode update
el.style.color = binding.value
}
})
// Vue 3 - update removed, use function shorthand or updated
app.directive('color', (el, binding) => {
// Function shorthand: called for both mounted AND updated
el.style.color = binding.value
})
// Or with object syntax
app.directive('color', {
mounted(el, binding) {
el.style.color = binding.value
},
updated(el, binding) {
// Use updated instead of update
el.style.color = binding.value
}
})
```
## Why `update` Was Removed
In Vue 2, `update` was called on every VNode update (before children updated), while `componentUpdated` was called after. The distinction was confusing and rarely needed. In Vue 3:
- `beforeUpdate` is called before the element updates
- `updated` is called after the element and all its children have updated
```javascript
// Vue 3 - if you need both before and after
app.directive('track-updates', {
beforeUpdate(el, binding) {
console.log('Before update, old value:', binding.oldValue)
},
updated(el, binding) {
console.log('After update, new value:', binding.value)
}
})
```
## vnode Structure Changes
In Vue 3, the `vnode` and `prevVnode` arguments also have different structure:
```javascript
// Vue 2
{
update(el, binding, vnode, oldVnode) {
// vnode.context was the component instance
console.log(vnode.context)
}
}
// Vue 3
{
updated(el, binding, vnode, prevVnode) {
// Use binding.instance instead of vnode.context
console.log(binding.instance)
}
}
```
## Reference
- [Vue 3 Migration Guide - Custom Directives](https://v3-migration.vuejs.org/breaking-changes/custom-directives)
- [Vue.js Custom Directives - Directive Hooks](https://vuejs.org/guide/reusability/custom-directives#directive-hooks)

View File

@@ -0,0 +1,147 @@
---
title: Use import.meta.glob for Dynamic Component Registration in Vite
impact: MEDIUM
impactDescription: require.context from Webpack doesn't work in Vite projects
type: gotcha
tags: [vue3, component-registration, vite, dynamic-import, migration, webpack]
---
# Use import.meta.glob for Dynamic Component Registration in Vite
**Impact: MEDIUM** - When migrating from Webpack to Vite or starting a new Vite project, the `require.context` pattern for dynamically registering components won't work. Vite uses `import.meta.glob` instead. Using the wrong approach will cause build errors or runtime failures.
## Task Checklist
- [ ] Replace `require.context` with `import.meta.glob` in Vite projects
- [ ] Update component registration patterns when migrating from Vue CLI to Vite
- [ ] Use `{ eager: true }` for synchronous loading when needed
- [ ] Handle async components appropriately with `defineAsyncComponent`
**Incorrect (Webpack pattern - doesn't work in Vite):**
```javascript
// main.js - WRONG for Vite
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// This Webpack-specific API doesn't exist in Vite
const requireComponent = require.context(
'./components/base',
false,
/Base[A-Z]\w+\.vue$/
)
requireComponent.keys().forEach(fileName => {
const componentConfig = requireComponent(fileName)
const componentName = fileName
.split('/')
.pop()
.replace(/\.\w+$/, '')
app.component(componentName, componentConfig.default || componentConfig)
})
app.mount('#app')
```
**Correct (Vite pattern):**
```javascript
// main.js - Correct for Vite
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// Vite's glob import - eager loading for synchronous registration
const modules = import.meta.glob('./components/base/Base*.vue', { eager: true })
for (const path in modules) {
// Extract component name from path: './components/base/BaseButton.vue' -> 'BaseButton'
const componentName = path.split('/').pop().replace('.vue', '')
app.component(componentName, modules[path].default)
}
app.mount('#app')
```
## Lazy Loading with Async Components
```javascript
// main.js - Lazy loading variant
import { createApp, defineAsyncComponent } from 'vue'
import App from './App.vue'
const app = createApp(App)
// Without { eager: true }, returns functions that return Promises
const modules = import.meta.glob('./components/base/Base*.vue')
for (const path in modules) {
const componentName = path.split('/').pop().replace('.vue', '')
// Wrap in defineAsyncComponent for lazy loading
app.component(componentName, defineAsyncComponent(modules[path]))
}
app.mount('#app')
```
## Glob Pattern Examples
```javascript
// All .vue files in a directory (not recursive)
import.meta.glob('./components/*.vue', { eager: true })
// All .vue files recursively
import.meta.glob('./components/**/*.vue', { eager: true })
// Specific naming pattern
import.meta.glob('./components/Base*.vue', { eager: true })
// Multiple patterns
import.meta.glob([
'./components/Base*.vue',
'./components/App*.vue'
], { eager: true })
// Exclude patterns
import.meta.glob('./components/**/*.vue', {
eager: true,
ignore: ['**/*.test.vue', '**/*.spec.vue']
})
```
## TypeScript Support
```typescript
// main.ts - with proper typing
import { createApp, Component } from 'vue'
import App from './App.vue'
const app = createApp(App)
const modules = import.meta.glob<{ default: Component }>(
'./components/base/Base*.vue',
{ eager: true }
)
for (const path in modules) {
const componentName = path.split('/').pop()!.replace('.vue', '')
app.component(componentName, modules[path].default)
}
app.mount('#app')
```
## Migration Checklist (Webpack to Vite)
| Webpack | Vite |
|---------|------|
| `require.context(dir, recursive, regex)` | `import.meta.glob(pattern, options)` |
| Synchronous by default | Use `{ eager: true }` for sync |
| `.keys()` returns array | Returns object with paths as keys |
| Returns module directly | Access via `.default` for ES modules |
## Reference
- [Vite - Glob Import](https://vitejs.dev/guide/features.html#glob-import)
- [Vue.js Component Registration](https://vuejs.org/guide/components/registration.html)

View File

@@ -0,0 +1,233 @@
---
title: Use KeepAlive to Preserve Dynamic Component State
impact: MEDIUM
impactDescription: Dynamic component switching destroys and recreates components, losing all internal state unless wrapped in KeepAlive
type: best-practice
tags: [vue3, dynamic-components, keepalive, component-is, state-preservation, performance]
---
# Use KeepAlive to Preserve Dynamic Component State
**Impact: MEDIUM** - When switching between components using `<component :is="...">`, Vue destroys the old component and creates a new one. All internal state (form inputs, scroll position, fetched data) is lost. Wrapping dynamic components in `<KeepAlive>` caches them and preserves their state.
## Task Checklist
- [ ] Wrap `<component :is>` with `<KeepAlive>` when state preservation is needed
- [ ] Use `include` and `exclude` to control which components are cached
- [ ] Use `max` to limit cache size and prevent memory issues
- [ ] Implement `onActivated`/`onDeactivated` hooks for cache-aware logic
- [ ] Consider NOT using KeepAlive when fresh state is desired
## The Problem: State Loss
```vue
<script setup>
import { ref, shallowRef } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'
const currentTab = shallowRef(TabA)
</script>
<template>
<button @click="currentTab = TabA">Tab A</button>
<button @click="currentTab = TabB">Tab B</button>
<!-- State is lost when switching tabs! -->
<component :is="currentTab" />
</template>
```
If TabA has a form with user input, switching to TabB and back resets all input.
## Solution: KeepAlive
```vue
<template>
<button @click="currentTab = TabA">Tab A</button>
<button @click="currentTab = TabB">Tab B</button>
<!-- State is preserved when switching -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
</template>
```
Now TabA's state persists even when TabB is displayed.
## Controlling What Gets Cached
### Include/Exclude by Name
Only cache specific components:
```vue
<template>
<!-- Only cache components named 'TabA' or 'TabB' -->
<KeepAlive include="TabA,TabB">
<component :is="currentTab" />
</KeepAlive>
<!-- Cache all except 'HeavyComponent' -->
<KeepAlive exclude="HeavyComponent">
<component :is="currentTab" />
</KeepAlive>
<!-- Using regex -->
<KeepAlive :include="/^Tab/">
<component :is="currentTab" />
</KeepAlive>
<!-- Using array -->
<KeepAlive :include="['TabA', 'TabB', 'Settings']">
<component :is="currentTab" />
</KeepAlive>
</template>
```
**Important:** Components must have a `name` option to be matched:
```vue
<!-- TabA.vue -->
<script>
export default {
name: 'TabA' // Required for include/exclude matching
}
</script>
<script setup>
// ...composition API code
</script>
```
Or in Vue 3.3+ with `<script setup>`:
```vue
<script setup>
defineOptions({
name: 'TabA'
})
</script>
```
### Limit Cache Size
Prevent memory issues with many cached components:
```vue
<template>
<!-- Only keep last 5 components in cache -->
<KeepAlive :max="5">
<component :is="currentTab" />
</KeepAlive>
</template>
```
When cache exceeds `max`, the least recently accessed component is destroyed.
## Lifecycle Hooks: onActivated and onDeactivated
Cached components need special lifecycle hooks:
```vue
<!-- TabA.vue -->
<script setup>
import { onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
// Only called on first mount, NOT when switching back
onMounted(() => {
console.log('TabA mounted (once)')
})
// Only called when truly destroyed, NOT when switching away
onUnmounted(() => {
console.log('TabA unmounted')
})
// Called EVERY time component becomes visible
onActivated(() => {
console.log('TabA activated')
// Refresh data, resume timers, etc.
fetchLatestData()
})
// Called EVERY time component is hidden (but kept in cache)
onDeactivated(() => {
console.log('TabA deactivated')
// Pause timers, save draft, etc.
pauseAutoRefresh()
})
</script>
```
**Common use cases for activation hooks:**
- Refresh stale data when returning to a tab
- Resume/pause video or audio playback
- Reconnect/disconnect WebSocket connections
- Save/restore scroll position
- Track analytics for tab views
## KeepAlive with Vue Router
For route-based caching:
```vue
<!-- App.vue -->
<template>
<router-view v-slot="{ Component }">
<KeepAlive include="Dashboard,Settings">
<component :is="Component" />
</KeepAlive>
</router-view>
</template>
```
**With transition:**
```vue
<template>
<router-view v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<KeepAlive>
<component :is="Component" :key="$route.fullPath" />
</KeepAlive>
</Transition>
</router-view>
</template>
```
## When NOT to Use KeepAlive
Don't cache when:
```vue
<!-- Fresh data needed each time -->
<template>
<!-- NO KeepAlive - want fresh search results each visit -->
<component :is="currentView" />
</template>
```
- Form should reset between visits
- Data must be fresh (real-time dashboards)
- Component has significant memory footprint
- Security-sensitive data should be cleared
## Performance Considerations
```vue
<script setup>
import { shallowRef } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'
// Use shallowRef for component references
// Regular ref would deeply track the component object unnecessarily
const currentTab = shallowRef(TabA)
</script>
```
## Reference
- [Vue.js KeepAlive](https://vuejs.org/guide/built-ins/keep-alive.html)
- [Vue.js Dynamic Components](https://vuejs.org/guide/essentials/component-basics.html#dynamic-components)

View File

@@ -0,0 +1,166 @@
---
title: Use kebab-case for Event Listeners in Templates
impact: LOW
impactDescription: Vue auto-converts camelCase emits to kebab-case listeners but consistency improves readability
type: best-practice
tags: [vue3, events, emit, naming-convention, templates]
---
# Use kebab-case for Event Listeners in Templates
**Impact: LOW** - Vue automatically converts event names between camelCase and kebab-case. You can emit in camelCase (`emit('someEvent')`) and listen with kebab-case (`@some-event`). However, following consistent conventions improves code readability and matches HTML attribute conventions.
## Task Checklist
- [ ] Emit events using camelCase in JavaScript: `emit('updateValue')`
- [ ] Listen to events using kebab-case in templates: `@update-value`
- [ ] Be consistent across your codebase
- [ ] Understand Vue's automatic case conversion
## The Convention
**Recommended pattern:**
```vue
<!-- ChildComponent.vue -->
<script setup>
const emit = defineEmits(['updateValue', 'itemSelected', 'formSubmit'])
function handleChange(value) {
// Emit in camelCase (JavaScript convention)
emit('updateValue', value)
}
function selectItem(item) {
emit('itemSelected', item)
}
</script>
```
```vue
<!-- ParentComponent.vue -->
<template>
<!-- Listen in kebab-case (HTML attribute convention) -->
<ChildComponent
@update-value="handleUpdate"
@item-selected="handleSelect"
@form-submit="handleSubmit"
/>
</template>
```
## Vue's Automatic Conversion
Vue handles these automatically **in template syntax only**:
| Emitted (camelCase) | Listener (kebab-case) | Works? |
|---------------------|----------------------|--------|
| `emit('updateValue')` | `@update-value` | Yes |
| `emit('itemSelected')` | `@item-selected` | Yes |
| `emit('formSubmit')` | `@form-submit` | Yes |
```vue
<!-- All of these work equivalently in Vue 3 templates -->
<Child @updateValue="handler" /> <!-- camelCase listener -->
<Child @update-value="handler" /> <!-- kebab-case listener (preferred) -->
```
### Important: Template-Only Behavior
This auto-conversion **only works in template syntax** (`@event-name`). It does **NOT** work in render functions or programmatic event listeners:
```ts
// In render functions, use camelCase with 'on' prefix
import { h } from 'vue'
// CORRECT - camelCase event name with 'on' prefix
h(ChildComponent, {
onUpdateValue: (value) => handleUpdate(value),
onItemSelected: (item) => handleSelect(item)
})
// WRONG - kebab-case does NOT work in render functions
h(ChildComponent, {
'onUpdate-value': (value) => handleUpdate(value), // Won't work!
'on-update-value': (value) => handleUpdate(value) // Won't work!
})
```
```ts
// Programmatic listeners also require camelCase
import { ref, onMounted } from 'vue'
const childRef = ref<ComponentPublicInstance | null>(null)
onMounted(() => {
// CORRECT - camelCase
childRef.value?.$on?.('updateValue', handler)
// WRONG - kebab-case won't match
childRef.value?.$on?.('update-value', handler) // Won't work!
})
```
**Summary:**
- **Templates**: Auto-conversion works (`@update-value` matches `emit('updateValue')`)
- **Render functions**: Must use `onEventName` format (camelCase with `on` prefix)
- **Programmatic listeners**: Must use the exact emitted event name (typically camelCase)
## Why kebab-case in Templates?
1. **HTML convention**: HTML attributes are case-insensitive and traditionally kebab-case
2. **Consistency with props**: Props follow the same pattern (`props.userName` -> `user-name="..."`)
3. **Readability**: `@user-profile-updated` is easier to read than `@userProfileUpdated`
4. **Vue style guide**: Vue's official style guide recommends this pattern
## TypeScript Declarations
When using TypeScript, define emits in camelCase:
```vue
<script setup lang="ts">
const emit = defineEmits<{
updateValue: [value: string] // camelCase
itemSelected: [item: Item] // camelCase
'update:modelValue': [value: string] // Special v-model syntax (with colon)
}>()
</script>
```
## v-model Events
For v-model, the `update:` prefix uses a colon, not kebab-case:
```vue
<script setup>
// Correct: colon separator for v-model updates
const emit = defineEmits(['update:modelValue', 'update:firstName'])
function updateValue(newValue) {
emit('update:modelValue', newValue)
}
</script>
```
```vue
<!-- Parent - v-model handles the event automatically -->
<CustomInput v-model="value" />
<CustomInput v-model:first-name="firstName" />
```
## Vue 2 Difference
In Vue 2, event names did NOT have automatic case conversion. This caused issues:
```js
// Vue 2 - camelCase events couldn't be listened to in templates
this.$emit('updateValue') // Emitted as 'updateValue'
// Template converts to lowercase
<child @updatevalue="handler"> // Listened as 'updatevalue' - NO MATCH!
```
Vue 3 fixed this with automatic camelCase-to-kebab-case conversion.
## Reference
- [Vue.js Component Events](https://vuejs.org/guide/components/events.html)
- [Vue.js Style Guide - Event Names](https://vuejs.org/style-guide/)

View File

@@ -0,0 +1,190 @@
---
title: Use Event Validation for Complex Payloads
impact: LOW
impactDescription: Event validation catches payload errors early with console warnings during development
type: best-practice
tags: [vue3, emits, defineEmits, validation, debugging]
---
# Use Event Validation for Complex Payloads
**Impact: LOW** - Vue allows you to validate event payloads using object syntax for `defineEmits`. When a validation function returns `false`, Vue logs a console warning. This helps catch bugs early during development, especially for events with complex payload requirements.
## Task Checklist
- [ ] Use object syntax for `defineEmits` when validation is needed
- [ ] Return `true` for valid payloads, `false` for invalid
- [ ] Add meaningful console warnings in validators
- [ ] Consider TypeScript for compile-time validation instead
## Basic Validation
**Using object syntax with validators:**
```vue
<script setup>
const emit = defineEmits({
// No validation - just declaration
cancel: null,
// Validate that email is present
submit: (payload) => {
if (!payload || !payload.email) {
console.warn('Submit event requires an email field')
return false
}
if (!payload.email.includes('@')) {
console.warn('Submit event requires a valid email')
return false
}
return true
},
// Validate ID is a number
select: (id) => {
if (typeof id !== 'number') {
console.warn('Select event requires a numeric ID')
return false
}
return true
}
})
function handleSubmit(formData) {
// If validation fails, Vue logs warning but event still emits
emit('submit', formData)
}
</script>
```
## What Happens on Validation Failure
```vue
<script setup>
const emit = defineEmits({
submit: (data) => {
if (!data?.email) {
return false // Validation fails
}
return true
}
})
function badSubmit() {
emit('submit', {}) // Missing email
// Console: [Vue warn]: Invalid event arguments: event validation failed for event "submit"
// Event STILL fires - validation is advisory only
}
</script>
```
**Important:** Validation failure only logs a warning. The event still emits. This is intentional for development debugging, not runtime enforcement.
## Common Validation Patterns
### Required Fields
```vue
const emit = defineEmits({
'user-created': (user) => {
const required = ['id', 'name', 'email']
const missing = required.filter(field => !user?.[field])
if (missing.length) {
console.warn(`user-created missing fields: ${missing.join(', ')}`)
return false
}
return true
}
})
```
### Type Checking
```vue
const emit = defineEmits({
'page-change': (page) => typeof page === 'number' && page > 0,
'items-selected': (items) => Array.isArray(items),
'filter-applied': (filter) => {
return filter && typeof filter.field === 'string' && filter.value !== undefined
}
})
```
### Range Validation
```vue
const emit = defineEmits({
'rating-change': (rating) => {
if (typeof rating !== 'number' || rating < 1 || rating > 5) {
console.warn('Rating must be a number between 1 and 5')
return false
}
return true
}
})
```
## TypeScript Alternative
For compile-time validation, prefer TypeScript types over runtime validators:
```vue
<script setup lang="ts">
interface SubmitPayload {
email: string
password: string
rememberMe?: boolean
}
const emit = defineEmits<{
submit: [payload: SubmitPayload]
cancel: []
'page-change': [page: number]
}>()
// TypeScript catches errors at compile time
emit('submit', { email: 'test@test.com' })
// Error: Property 'password' is missing
</script>
```
TypeScript validation is:
- Caught at compile time, not runtime
- Provides IDE autocompletion
- Zero runtime overhead
## When to Use Runtime Validation
Use object syntax validation when:
- You're not using TypeScript
- You need to validate values that can't be expressed in types (ranges, formats)
- You want runtime debugging help during development
- You're building a component library and want helpful dev warnings
## Combining Both Approaches
```vue
<script setup lang="ts">
interface FormData {
email: string
age: number
}
// TypeScript handles type structure
// Runtime validator handles value constraints
const emit = defineEmits({
submit: (data: FormData) => {
if (data.age < 0 || data.age > 150) {
console.warn('Age must be between 0 and 150')
return false
}
if (!data.email.includes('@')) {
console.warn('Invalid email format')
return false
}
return true
}
})
</script>
```
## Reference
- [Vue.js Component Events - Events Validation](https://vuejs.org/guide/components/events.html#events-validation)

View File

@@ -0,0 +1,213 @@
---
title: Use .once Modifier for Single-Use Event Handlers
impact: LOW
impactDescription: The .once modifier auto-removes event listeners after first trigger, preventing repeated handler calls
type: best-practice
tags: [vue3, events, modifiers, once, event-handling]
---
# Use .once Modifier for Single-Use Event Handlers
**Impact: LOW** - Vue provides a `.once` modifier for event listeners that automatically removes the listener after it fires once. This is useful for one-time events like initialization callbacks, first-interaction tracking, or one-time animations.
## Task Checklist
- [ ] Use `.once` for events that should only fire once
- [ ] Consider `.once` for analytics first-interaction tracking
- [ ] Use `.once` for initialization events
- [ ] Remember `.once` works on both native and component events
## Basic Usage
**Component events:**
```vue
<template>
<!-- Handler fires only on first emit, then stops listening -->
<ChildComponent @initialized.once="handleInit" />
<!-- First-time user interaction tracking -->
<UserForm @submit.once="trackFirstSubmit" />
</template>
<script setup>
function handleInit(data) {
console.log('Component initialized:', data)
// This only runs once, even if child emits 'initialized' multiple times
}
function trackFirstSubmit() {
analytics.track('first_form_submit')
}
</script>
```
**Native DOM events:**
```vue
<template>
<!-- First click only -->
<button @click.once="showWelcomeMessage">
Click to see welcome (once)
</button>
<!-- First scroll only -->
<div @scroll.once="loadMoreContent">
Scroll to load more
</div>
</template>
```
## Common Use Cases
### One-Time Initialization
```vue
<template>
<ThirdPartyChart
@ready.once="onChartReady"
:data="chartData"
/>
</template>
<script setup>
function onChartReady(chartInstance) {
// Store reference, configure chart
// Only need to do this once
chartRef.value = chartInstance
chartInstance.setOption(customOptions)
}
</script>
```
### First Interaction Analytics
```vue
<template>
<article @click.once="trackEngagement">
<h2>{{ article.title }}</h2>
<p>{{ article.excerpt }}</p>
</article>
</template>
<script setup>
function trackEngagement() {
analytics.track('article_first_click', {
articleId: article.id
})
}
</script>
```
### Lazy Loading Trigger
```vue
<template>
<div
ref="lazyContainer"
@mouseenter.once="loadHeavyContent"
>
<template v-if="loaded">
<HeavyComponent :data="data" />
</template>
<template v-else>
<Placeholder />
</template>
</div>
</template>
<script setup>
const loaded = ref(false)
const data = ref(null)
async function loadHeavyContent() {
data.value = await fetchData()
loaded.value = true
}
</script>
```
### One-Time Animation
```vue
<template>
<Transition
@after-enter.once="onFirstAppearance"
>
<div v-if="show" class="animated-content">
Content
</div>
</Transition>
</template>
<script setup>
function onFirstAppearance() {
// Track first time user sees this element
// Won't fire again if element leaves and re-enters
}
</script>
```
## Combining with Other Modifiers
```vue
<template>
<!-- Once + prevent default -->
<form @submit.once.prevent="handleFirstSubmit">
...
</form>
<!-- Once + stop propagation -->
<div @click.once.stop="handleClick">
...
</div>
<!-- Once + key modifier -->
<input @keyup.enter.once="submitOnFirstEnter" />
</template>
```
## Equivalent Manual Implementation
Without `.once`, you'd need to manually track and remove:
```vue
<script setup>
const hasHandled = ref(false)
function handleClickManually() {
if (hasHandled.value) return
hasHandled.value = true
// Do one-time action
doSomething()
}
</script>
<template>
<!-- Manual approach - more verbose -->
<button @click="handleClickManually">Click</button>
<!-- .once approach - cleaner -->
<button @click.once="doSomething">Click</button>
</template>
```
## When NOT to Use .once
Don't use `.once` when:
- You need the event to fire multiple times
- You want to conditionally allow repeated fires
- The "once" logic is complex (use manual ref tracking instead)
```vue
<template>
<!-- DON'T: User expects multiple submissions to work -->
<form @submit.once.prevent="handleSubmit">
...
</form>
<!-- DO: Allow repeated submissions -->
<form @submit.prevent="handleSubmit">
...
</form>
</template>
```
## Reference
- [Vue.js Event Handling - Event Modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers)
- [Vue.js Component Events](https://vuejs.org/guide/components/events.html)

View File

@@ -0,0 +1,155 @@
---
title: Use .exact Modifier for Precise Keyboard/Mouse Shortcuts
impact: MEDIUM
impactDescription: Without .exact, shortcuts fire even when additional modifier keys are pressed, causing unintended behavior
type: best-practice
tags: [vue3, events, keyboard, modifiers, shortcuts, accessibility]
---
# Use .exact Modifier for Precise Keyboard/Mouse Shortcuts
**Impact: MEDIUM** - By default, Vue's modifier key handlers (`.ctrl`, `.alt`, `.shift`, `.meta`) fire even when other modifier keys are also pressed. Use `.exact` to require that ONLY the specified modifiers are pressed, preventing accidental triggering of shortcuts.
## Task Checklist
- [ ] Use `.exact` when you need precise modifier combinations
- [ ] Without `.exact`: `@click.ctrl` fires for Ctrl+Click AND Ctrl+Shift+Click
- [ ] With `.exact`: `@click.ctrl.exact` fires ONLY for Ctrl+Click
- [ ] Use `@click.exact` for plain clicks with no modifiers
**Incorrect:**
```html
<!-- WRONG: Fires even with additional modifiers -->
<template>
<button @click.ctrl="copyItem">Copy</button>
<!-- Also fires on Ctrl+Shift+Click, Ctrl+Alt+Click, etc. -->
<button @click.ctrl.shift="copyAll">Copy All</button>
<!-- User expects Ctrl+Shift, but also fires on Ctrl+Shift+Alt -->
</template>
```
```html
<!-- WRONG: Conflicting shortcuts without .exact -->
<template>
<div>
<button @click.ctrl="copy">Copy (Ctrl+Click)</button>
<button @click.ctrl.shift="copyAll">Copy All (Ctrl+Shift+Click)</button>
<!-- Both fire when user does Ctrl+Shift+Click! -->
</div>
</template>
```
**Correct:**
```html
<!-- CORRECT: Precise modifier matching with .exact -->
<template>
<button @click.ctrl.exact="copyItem">Copy (Ctrl only)</button>
<!-- Only fires on Ctrl+Click, not Ctrl+Shift+Click -->
<button @click.ctrl.shift.exact="copyAll">Copy All (Ctrl+Shift only)</button>
<!-- Only fires on Ctrl+Shift+Click, not Ctrl+Shift+Alt+Click -->
</template>
```
```html
<!-- CORRECT: Plain click without any modifiers -->
<template>
<button @click.exact="selectItem">Select</button>
<!-- Only fires when NO modifier keys are pressed -->
<!-- Ctrl+Click, Shift+Click, etc. will NOT trigger this -->
</template>
```
```html
<!-- CORRECT: Non-conflicting shortcuts -->
<template>
<div class="editor">
<div
@click.exact="selectItem"
@click.ctrl.exact="addToSelection"
@click.shift.exact="extendSelection"
@click.ctrl.shift.exact="selectRange"
>
Click, Ctrl+Click, Shift+Click, or Ctrl+Shift+Click
</div>
</div>
</template>
```
## Behavior Comparison
```javascript
// WITHOUT .exact
@click.ctrl="handler"
// Fires when: Ctrl+Click, Ctrl+Shift+Click, Ctrl+Alt+Click, Ctrl+Shift+Alt+Click
// Does NOT fire: Click (without Ctrl)
// WITH .exact
@click.ctrl.exact="handler"
// Fires when: ONLY Ctrl+Click
// Does NOT fire: Ctrl+Shift+Click, Ctrl+Alt+Click, Click
// ONLY .exact (no other modifiers)
@click.exact="handler"
// Fires when: Plain click with NO modifiers
// Does NOT fire: Ctrl+Click, Shift+Click, Alt+Click
```
## Practical Example: File Browser Selection
```vue
<template>
<ul class="file-list">
<li
v-for="file in files"
:key="file.id"
@click.exact="selectSingle(file)"
@click.ctrl.exact="toggleSelection(file)"
@click.shift.exact="selectRange(file)"
@click.ctrl.shift.exact="addRangeToSelection(file)"
:class="{ selected: isSelected(file) }"
>
{{ file.name }}
</li>
</ul>
</template>
<script setup>
// Each click type has distinct, non-overlapping behavior
function selectSingle(file) {
// Clear selection and select only this file
}
function toggleSelection(file) {
// Add or remove this file from current selection
}
function selectRange(file) {
// Select all files from last selected to this one
}
function addRangeToSelection(file) {
// Add range to existing selection
}
</script>
```
## Keyboard Shortcuts with .exact
```html
<template>
<div
tabindex="0"
@keydown.ctrl.s.exact.prevent="save"
@keydown.ctrl.shift.s.exact.prevent="saveAs"
@keydown.ctrl.z.exact.prevent="undo"
@keydown.ctrl.shift.z.exact.prevent="redo"
>
<!-- Each shortcut is precisely defined -->
</div>
</template>
```
## Reference
- [Vue.js Event Handling - .exact Modifier](https://vuejs.org/guide/essentials/event-handling.html#exact-modifier)

View File

@@ -0,0 +1,218 @@
---
title: KeepAlive Include/Exclude Requires Component Name
impact: MEDIUM
impactDescription: The include and exclude props match against component name option, which must be explicitly declared
type: gotcha
tags: [vue3, keepalive, component-name, include, exclude, sfc]
---
# KeepAlive Include/Exclude Requires Component Name
**Impact: MEDIUM** - When using `include` or `exclude` props on KeepAlive, the matching is done against the component's `name` option. Components without an explicit name will not match and caching behavior will be unexpected.
## Task Checklist
- [ ] Declare `name` option on components used with include/exclude
- [ ] Use `defineOptions({ name: '...' })` in `<script setup>`
- [ ] Note: Since Vue 3.2.34, SFC filename is auto-inferred as name
- [ ] Verify names match exactly (case-sensitive)
## The Problem
```vue
<!-- App.vue -->
<template>
<KeepAlive include="TabA,TabB">
<component :is="currentTab" />
</KeepAlive>
</template>
```
```vue
<!-- TabA.vue - NO NAME DECLARED -->
<script setup>
// No name option - will NOT match "TabA" in include!
const count = ref(0)
</script>
<template>
<div>Count: {{ count }}</div>
</template>
```
**Result:** TabA is NOT cached because it has no `name` option to match against.
## Solutions
### Solution 1: Use defineOptions (Vue 3.3+)
```vue
<!-- TabA.vue -->
<script setup>
import { ref } from 'vue'
defineOptions({
name: 'TabA' // Now matches include="TabA"
})
const count = ref(0)
</script>
<template>
<div>Count: {{ count }}</div>
</template>
```
### Solution 2: Dual Script Block
```vue
<!-- TabA.vue -->
<script>
export default {
name: 'TabA'
}
</script>
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<div>Count: {{ count }}</div>
</template>
```
### Solution 3: Rely on Auto-Inference (Vue 3.2.34+)
Since Vue 3.2.34, SFCs using `<script setup>` automatically infer their name from the filename:
```
TabA.vue -> name is "TabA"
UserProfile.vue -> name is "UserProfile"
my-component.vue -> name is "my-component" (kebab-case preserved)
```
```vue
<!-- Works automatically since Vue 3.2.34+ -->
<!-- File: TabA.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<div>Count: {{ count }}</div>
</template>
```
```vue
<!-- App.vue -->
<template>
<!-- Matches filename "TabA" -->
<KeepAlive include="TabA">
<component :is="currentTab" />
</KeepAlive>
</template>
```
## Common Mistakes
### Mistake 1: Name Doesn't Match
```vue
<script>
export default {
name: 'tab-a' // lowercase kebab-case
}
</script>
```
```vue
<template>
<!-- DOESN'T MATCH - include uses PascalCase -->
<KeepAlive include="TabA">
<component :is="currentTab" />
</KeepAlive>
</template>
```
**Fix:** Ensure names match exactly:
```vue
<KeepAlive include="tab-a"> <!-- or change component name to 'TabA' -->
```
### Mistake 2: Dynamic Components Without Names
```vue
<script setup>
import { defineAsyncComponent } from 'vue'
// Async component - might not have name!
const AsyncTab = defineAsyncComponent(() => import('./Tab.vue'))
</script>
```
**Fix:** Ensure the imported component has a name declared.
### Mistake 3: Using Props in Options API
```vue
<script>
export default {
// This doesn't set the component name!
props: ['name'] // 'name' prop is different from component name option
}
</script>
```
## Debugging Name Issues
Check what name Vue sees for your component:
```vue
<script setup>
import { getCurrentInstance, onMounted } from 'vue'
onMounted(() => {
const instance = getCurrentInstance()
console.log('Component name:', instance?.type?.name)
console.log('Component __name:', instance?.type?.__name)
})
</script>
```
## Using Different Match Formats
```vue
<template>
<!-- String (comma-delimited) -->
<KeepAlive include="TabA,TabB,TabC">
<component :is="current" />
</KeepAlive>
<!-- RegExp -->
<KeepAlive :include="/^Tab/">
<component :is="current" />
</KeepAlive>
<!-- Array -->
<KeepAlive :include="['TabA', 'TabB']">
<component :is="current" />
</KeepAlive>
</template>
```
## Key Points
1. **Name must match exactly** - Case-sensitive string matching
2. **Vue 3.2.34+ auto-infers name** - From filename for `<script setup>` SFCs
3. **Use `defineOptions`** - Clean way to set name in `<script setup>`
4. **Debug with getCurrentInstance** - Check what name Vue actually sees
5. **Multiple formats available** - String, RegExp, or Array for include/exclude
## Reference
- [Vue.js KeepAlive - Include/Exclude](https://vuejs.org/guide/built-ins/keep-alive.html#include-exclude)
- [Vue.js SFC `<script setup>`](https://vuejs.org/api/sfc-script-setup.html)
- [defineOptions documentation](https://vuejs.org/api/sfc-script-setup.html#defineoptions)

View File

@@ -0,0 +1,186 @@
---
title: KeepAlive Memory Management - Prevent Memory Leaks
impact: HIGH
impactDescription: Unbounded KeepAlive caching can cause severe memory issues, especially with large or numerous components
type: gotcha
tags: [vue3, keepalive, memory-leak, performance, cache, max-prop]
---
# KeepAlive Memory Management - Prevent Memory Leaks
**Impact: HIGH** - KeepAlive caches component instances in memory. Without proper limits and cleanup, this can lead to memory exhaustion, especially in applications with many routes or large component trees.
## Task Checklist
- [ ] Always use the `max` prop to limit cached instances
- [ ] Clean up resources in `onDeactivated` hook
- [ ] Monitor memory usage when using KeepAlive extensively
- [ ] Be cautious with KeepAlive on memory-heavy components
- [ ] Test on memory-constrained devices (mobile, low-spec laptops)
## The Problem: Unbounded Cache Growth
```vue
<template>
<!-- DANGEROUS: No limit on cached components -->
<KeepAlive>
<component :is="currentView" />
</KeepAlive>
</template>
```
When users navigate through many different components, each instance stays in memory:
- Chrome memory grows continuously
- VueComponent count keeps increasing
- App becomes sluggish or crashes
## Solution: Set Cache Limits
```vue
<template>
<!-- LRU cache: keeps max 10, evicts least recently used -->
<KeepAlive :max="10">
<component :is="currentView" />
</KeepAlive>
</template>
```
**How `max` works:**
- KeepAlive uses an LRU (Least Recently Used) cache algorithm
- When cache exceeds `max`, the oldest unused component is destroyed
- This caps memory usage to a predictable maximum
### Choose `max` Based on Your Use Case
```vue
<template>
<!-- Tab-based UI: usually 3-5 tabs max -->
<KeepAlive :max="5">
<component :is="currentTab" />
</KeepAlive>
<!-- Route-based caching: limit to frequently visited pages -->
<router-view v-slot="{ Component }">
<KeepAlive :max="3" include="Dashboard,Settings,Profile">
<component :is="Component" />
</KeepAlive>
</router-view>
</template>
```
## Clean Up Resources in Deactivated Hook
Even with `max`, cached components hold resources. Clean up when deactivated:
```vue
<script setup>
import { ref, onActivated, onDeactivated, onUnmounted } from 'vue'
const chartInstance = ref(null)
const websocket = ref(null)
const intervalId = ref(null)
onActivated(() => {
// Resume or recreate resources
websocket.value = new WebSocket('...')
intervalId.value = setInterval(fetchData, 5000)
})
onDeactivated(() => {
// IMPORTANT: Clean up to reduce memory footprint while cached
chartInstance.value?.destroy()
chartInstance.value = null
websocket.value?.close()
websocket.value = null
clearInterval(intervalId.value)
intervalId.value = null
})
// Final cleanup when truly destroyed
onUnmounted(() => {
// Same cleanup for when component is evicted from cache
chartInstance.value?.destroy()
websocket.value?.close()
clearInterval(intervalId.value)
})
</script>
```
## Third-Party Library Cleanup
Libraries that manipulate the DOM outside Vue need explicit cleanup:
```vue
<script setup>
import { ref, onMounted, onDeactivated, onUnmounted } from 'vue'
const mapContainer = ref(null)
let mapInstance = null
onMounted(() => {
mapInstance = new MapLibrary(mapContainer.value)
})
onDeactivated(() => {
// Some map libraries hold significant memory
// Destroy and recreate on reactivation if needed
mapInstance?.remove()
mapInstance = null
})
onUnmounted(() => {
mapInstance?.remove()
mapInstance = null
})
</script>
```
## Avoid KeepAlive for Memory-Heavy Components
Some components should NOT be cached:
```vue
<script setup>
const heavyComponents = ['VideoPlayer', 'LargeDataGrid', 'MapView']
</script>
<template>
<!-- Exclude memory-heavy components from cache -->
<KeepAlive :exclude="heavyComponents" :max="5">
<component :is="currentView" />
</KeepAlive>
</template>
```
## Monitor Memory in Development
```vue
<script setup>
import { onActivated, onDeactivated } from 'vue'
if (import.meta.env.DEV) {
onActivated(() => {
console.log('Activated - Memory:', performance.memory?.usedJSHeapSize)
})
onDeactivated(() => {
console.log('Deactivated - Memory:', performance.memory?.usedJSHeapSize)
})
}
</script>
```
## Key Points
1. **Always set `max`** - Never use KeepAlive without a reasonable limit
2. **Clean up in `onDeactivated`** - Don't wait for unmount to release resources
3. **Exclude heavy components** - Large data grids, media players, maps
4. **Test on target devices** - Mobile users have less memory
5. **Monitor in development** - Watch for growing memory usage
## Reference
- [Vue.js KeepAlive - Max Cached Instances](https://vuejs.org/guide/built-ins/keep-alive.html#max-cached-instances)
- [Vue.js Avoiding Memory Leaks](https://v2.vuejs.org/v2/cookbook/avoiding-memory-leaks.html)
- [GitHub Issue: Memory leak with keep-alive](https://github.com/vuejs/vue/issues/6759)

View File

@@ -0,0 +1,191 @@
---
title: Vue 3 KeepAlive Has No Direct Cache Removal API
impact: MEDIUM
impactDescription: Unlike Vue 2, there is no way to programmatically remove a specific component from KeepAlive cache in Vue 3
type: gotcha
tags: [vue3, keepalive, cache, migration, vue2-to-vue3]
---
# Vue 3 KeepAlive Has No Direct Cache Removal API
**Impact: MEDIUM** - Vue 3 removed the `$destroy()` method that Vue 2 developers used to indirectly clear KeepAlive cache entries. There is no direct API to remove a specific cached component in Vue 3.
## Task Checklist
- [ ] Do not rely on programmatic cache removal from Vue 2 patterns
- [ ] Use `include`/`exclude` props for dynamic cache control
- [ ] Use key changes to force cache invalidation
- [ ] Set appropriate `max` prop to auto-evict old entries
## The Problem
### Vue 2 Pattern (No Longer Works)
```javascript
// Vue 2: Could destroy specific component instance
this.$children[0].$destroy()
```
### Vue 3: No Equivalent API
```javascript
// Vue 3: $destroy() does not exist
// There is NO direct way to remove a specific cached instance
```
## Solutions
### Solution 1: Dynamic Include/Exclude
Control cache membership via reactive props:
```vue
<script setup>
import { ref, computed } from 'vue'
const cachedViews = ref(['Dashboard', 'Settings', 'Profile'])
function removeFromCache(viewName) {
cachedViews.value = cachedViews.value.filter(v => v !== viewName)
}
function addToCache(viewName) {
if (!cachedViews.value.includes(viewName)) {
cachedViews.value.push(viewName)
}
}
</script>
<template>
<KeepAlive :include="cachedViews">
<component :is="currentView" />
</KeepAlive>
</template>
```
When a component is removed from `include`, it will be destroyed on next switch.
### Solution 2: Key-Based Cache Invalidation
Change the key to force a fresh instance:
```vue
<script setup>
import { ref, reactive } from 'vue'
const currentView = ref('Dashboard')
const viewKeys = reactive({
Dashboard: 0,
Settings: 0,
Profile: 0
})
function invalidateCache(viewName) {
viewKeys[viewName]++
}
function switchView(viewName) {
currentView.value = viewName
}
// Force fresh Dashboard on next visit
function refreshDashboard() {
invalidateCache('Dashboard')
}
</script>
<template>
<KeepAlive>
<component
:is="currentView"
:key="`${currentView}-${viewKeys[currentView]}`"
/>
</KeepAlive>
</template>
```
### Solution 3: Conditional KeepAlive
Wrap or unwrap based on cache need:
```vue
<script setup>
import { ref } from 'vue'
const currentView = ref('Dashboard')
const shouldCache = ref(true)
function clearCacheAndSwitch(viewName) {
shouldCache.value = false
currentView.value = viewName
// Re-enable caching after the switch
nextTick(() => {
shouldCache.value = true
})
}
</script>
<template>
<KeepAlive v-if="shouldCache">
<component :is="currentView" />
</KeepAlive>
<component v-else :is="currentView" />
</template>
```
### Solution 4: Use Max for Automatic Eviction
Let LRU cache handle cleanup:
```vue
<template>
<!-- Automatically evicts least recently used when cache is full -->
<KeepAlive :max="5">
<component :is="currentView" />
</KeepAlive>
</template>
```
## Vue Router: Clear Cache on Certain Navigations
```vue
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const cachedRoutes = ref(['Dashboard', 'Settings'])
// Clear specific route from cache when navigating from certain paths
watch(() => route.name, (newRoute, oldRoute) => {
if (oldRoute === 'Login') {
// User just logged in - clear and refresh Dashboard
cachedRoutes.value = cachedRoutes.value.filter(r => r !== 'Dashboard')
nextTick(() => {
cachedRoutes.value.push('Dashboard')
})
}
})
</script>
<template>
<router-view v-slot="{ Component }">
<KeepAlive :include="cachedRoutes">
<component :is="Component" />
</KeepAlive>
</router-view>
</template>
```
## Key Points
1. **No `$destroy()` in Vue 3** - Cannot directly remove cached instances
2. **Use dynamic `include`** - Reactively control which components are cached
3. **Use key changes** - Changing key creates a new cache entry
4. **Use `max` prop** - LRU eviction handles cleanup automatically
5. **Plan cache strategy** - Design around these constraints upfront
## Reference
- [Vue.js KeepAlive Documentation](https://vuejs.org/guide/built-ins/keep-alive.html)
- [Vue 3 Migration Guide](https://v3-migration.vuejs.org/)
- [Vue RFC Discussion #283: Custom cache strategy for KeepAlive](https://github.com/vuejs/rfcs/discussions/283)

View File

@@ -0,0 +1,226 @@
---
title: KeepAlive Router Navigation Fresh vs Cached Problem
impact: MEDIUM
impactDescription: When using KeepAlive with Vue Router, users may get cached pages when they expect fresh content
type: gotcha
tags: [vue3, keepalive, vue-router, navigation, cache, ux]
---
# KeepAlive Router Navigation Fresh vs Cached Problem
**Impact: MEDIUM** - When using KeepAlive with Vue Router, navigation from menus or breadcrumbs may show cached (stale) content when users expect a fresh page. This creates confusing UX where the page appears "stuck" on old data.
## Task Checklist
- [ ] Define clear rules for when to use cached vs fresh pages
- [ ] Use route keys strategically to control freshness
- [ ] Implement `onActivated` to refresh stale data
- [ ] Consider navigation source when deciding cache behavior
## The Problem
```vue
<!-- App.vue -->
<template>
<nav>
<router-link to="/products">Products</router-link>
</nav>
<router-view v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</router-view>
</template>
```
**Scenario:**
1. User visits `/products?category=shoes` - sees shoes
2. User navigates to `/products?category=hats` - sees hats
3. User clicks "Products" nav link (to `/products`)
4. **Expected:** Fresh products page or default category
5. **Actual:** Still shows hats (cached state)!
Users clicking navigation expect a "fresh start" but get the cached state.
## Solutions
### Solution 1: Use Route Full Path as Key
```vue
<template>
<router-view v-slot="{ Component, route }">
<KeepAlive>
<!-- Different query params = different cache entry -->
<component :is="Component" :key="route.fullPath" />
</KeepAlive>
</router-view>
</template>
```
**Tradeoff:** Creates separate cache entry for each unique URL. May increase memory usage.
### Solution 2: Refresh Data on Activation
```vue
<!-- Products.vue -->
<script setup>
import { ref, onActivated, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const products = ref([])
const lastParams = ref(null)
async function fetchProducts() {
const params = route.query
products.value = await api.getProducts(params)
lastParams.value = JSON.stringify(params)
}
// Initial fetch
fetchProducts()
// Refresh when re-activated if params changed
onActivated(() => {
const currentParams = JSON.stringify(route.query)
if (currentParams !== lastParams.value) {
fetchProducts()
}
})
// Also watch for changes while component is active
watch(() => route.query, fetchProducts, { deep: true })
</script>
```
### Solution 3: Navigation-Aware Cache Control
Different behavior based on how user navigated:
```vue
<script setup>
import { ref, onActivated } from 'vue'
import { useRoute, useRouter, onBeforeRouteUpdate } from 'vue-router'
const route = useRoute()
const router = useRouter()
const shouldRefresh = ref(false)
// Mark for refresh when coming from nav link (no query params)
onBeforeRouteUpdate((to, from) => {
// If navigating to base path without params, user wants fresh view
if (Object.keys(to.query).length === 0 && Object.keys(from.query).length > 0) {
shouldRefresh.value = true
}
})
onActivated(() => {
if (shouldRefresh.value) {
resetToDefaultState()
shouldRefresh.value = false
}
})
function resetToDefaultState() {
// Reset filters, clear search, show default view
}
</script>
```
### Solution 4: Don't Cache Route-Dependent Pages
```vue
<script setup>
// Don't cache pages where query params significantly change content
const noCacheRoutes = ['ProductSearch', 'SearchResults', 'FilteredList']
</script>
<template>
<router-view v-slot="{ Component, route }">
<KeepAlive :exclude="noCacheRoutes">
<component :is="Component" />
</KeepAlive>
</router-view>
</template>
```
### Solution 5: Use Route Meta for Fresh Navigation
```javascript
// router.js
const routes = [
{
path: '/products',
component: Products,
meta: {
keepAlive: true,
refreshOnDirectNavigation: true
}
}
]
```
```vue
<!-- App.vue -->
<script setup>
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const forceRefreshKey = ref(0)
// Watch for navigation to routes that want fresh state
watch(
() => route.fullPath,
(newPath, oldPath) => {
// Direct navigation to base path = user wants fresh
if (route.meta.refreshOnDirectNavigation && !route.query.length) {
forceRefreshKey.value++
}
}
)
</script>
<template>
<router-view v-slot="{ Component }">
<KeepAlive>
<component
:is="Component"
:key="`${route.name}-${forceRefreshKey}`"
/>
</KeepAlive>
</router-view>
</template>
```
## Best Practice: Be Explicit About Cache Behavior
Document your caching rules:
```javascript
// cacheRules.js
export const CACHE_RULES = {
// Always cached - static content, user preferences
ALWAYS: ['Dashboard', 'Settings', 'Profile'],
// Never cached - dynamic search/filter results
NEVER: ['SearchResults', 'FilteredProducts'],
// Cached but refreshes on activation
STALE_WHILE_REVALIDATE: ['Notifications', 'Messages']
}
```
## Key Points
1. **User expectation mismatch** - Nav links often imply "fresh" but get cached
2. **Use `fullPath` key carefully** - Prevents reuse but increases memory
3. **Implement `onActivated` refresh** - Check if data needs updating
4. **Don't cache filter/search pages** - These are highly query-dependent
5. **Document cache behavior** - Make rules explicit for your team
## Reference
- [Vue.js KeepAlive Documentation](https://vuejs.org/guide/built-ins/keep-alive.html)
- [Vue Router Navigation](https://router.vuejs.org/guide/essentials/navigation.html)
- [Stack Keep-Alive Library](https://github.com/Zippowxk/stack-keep-alive)

View File

@@ -0,0 +1,222 @@
---
title: KeepAlive with Nested Routes Double Mount Issue
impact: HIGH
impactDescription: Using KeepAlive with nested Vue Router routes can cause child components to mount twice
type: gotcha
tags: [vue3, keepalive, vue-router, nested-routes, double-mount, bug]
---
# KeepAlive with Nested Routes Double Mount Issue
**Impact: HIGH** - When using `<KeepAlive>` with nested Vue Router routes, child route components may mount twice. This is a known issue that can cause duplicate API calls, broken state, and confusing behavior.
## Task Checklist
- [ ] Test nested routes thoroughly when using KeepAlive
- [ ] Avoid mixing KeepAlive with deeply nested route structures
- [ ] Use workarounds if double mount is observed
- [ ] Consider alternative caching strategies for nested routes
## The Problem
```vue
<!-- App.vue -->
<template>
<router-view v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</router-view>
</template>
```
```javascript
// router.js
const routes = [
{
path: '/parent',
component: Parent,
children: [
{
path: 'child',
component: Child // This may mount TWICE!
}
]
}
]
```
**Symptoms:**
- `onMounted` called twice in child component
- Duplicate API requests
- State initialization runs twice
- Console logs appear doubled
## Diagnosis
Add logging to confirm the issue:
```vue
<!-- Child.vue -->
<script setup>
import { onMounted, onActivated } from 'vue'
let mountCount = 0
onMounted(() => {
mountCount++
console.log('Child mounted - count:', mountCount)
// If you see "count: 2", you have the double mount issue
})
onActivated(() => {
console.log('Child activated')
})
</script>
```
## Workarounds
### Option 1: Use `useActivatedRoute` Pattern
Don't use `useRoute()` directly with KeepAlive:
```vue
<script setup>
import { ref, onActivated } from 'vue'
import { useRoute } from 'vue-router'
// Problem: useRoute() can cause issues with KeepAlive
// const route = useRoute()
// Solution: Get route info in onActivated
const routeParams = ref({})
onActivated(() => {
const route = useRoute()
routeParams.value = { ...route.params }
})
</script>
```
### Option 2: Avoid KeepAlive for Nested Route Parents
Only cache leaf routes, not parent layouts:
```vue
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// Only cache specific leaf routes
const cachedRoutes = computed(() => {
// Don't cache parent routes that have children
return ['UserProfile', 'UserSettings'] // Only leaf components
})
</script>
<template>
<router-view v-slot="{ Component, route: currentRoute }">
<KeepAlive :include="cachedRoutes">
<component :is="Component" :key="currentRoute.fullPath" />
</KeepAlive>
</router-view>
</template>
```
### Option 3: Guard Against Double Initialization
Protect your component from double mount effects:
```vue
<script setup>
import { ref, onMounted } from 'vue'
const isInitialized = ref(false)
onMounted(() => {
if (isInitialized.value) {
console.warn('Double mount detected, skipping initialization')
return
}
isInitialized.value = true
// Safe to initialize
fetchData()
setupEventListeners()
})
</script>
```
### Option 4: Use Route-Level Cache Control
```vue
<!-- App.vue -->
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// Define which routes should be cached in route meta
const shouldCache = computed(() => {
return route.meta.keepAlive !== false
})
</script>
<template>
<router-view v-slot="{ Component }">
<KeepAlive v-if="shouldCache">
<component :is="Component" />
</KeepAlive>
<component v-else :is="Component" />
</router-view>
</template>
```
```javascript
// router.js
const routes = [
{
path: '/parent',
component: Parent,
meta: { keepAlive: false }, // Don't cache parent routes
children: [
{
path: 'child',
component: Child,
meta: { keepAlive: true } // Cache leaf routes
}
]
}
]
```
### Option 5: Flatten Route Structure
Avoid nesting if possible:
```javascript
// Instead of nested routes
const routes = [
// Flat structure avoids the issue
{ path: '/users', component: UserList },
{ path: '/users/:id', component: UserDetail },
{ path: '/users/:id/settings', component: UserSettings }
]
```
## Key Points
1. **Known Vue Router issue** - Double mount with KeepAlive + nested routes
2. **Watch for symptoms** - Duplicate API calls, doubled logs
3. **Avoid caching parent routes** - Only cache leaf components
4. **Add initialization guards** - Protect against double execution
5. **Test thoroughly** - This issue may not appear immediately
## Reference
- [Vue Router Issue #626: keep-alive in nested route mounted twice](https://github.com/vuejs/router/issues/626)
- [GitHub: vue3-keep-alive-component workaround](https://github.com/emiyalee1005/vue3-keep-alive-component)
- [Vue.js KeepAlive Documentation](https://vuejs.org/guide/built-ins/keep-alive.html)

View File

@@ -0,0 +1,144 @@
---
title: KeepAlive with Transition Memory Leak
impact: MEDIUM
impactDescription: Combining KeepAlive with Transition can cause memory leaks in certain Vue versions
type: gotcha
tags: [vue3, keepalive, transition, memory-leak, animation]
---
# KeepAlive with Transition Memory Leak
**Impact: MEDIUM** - There is a known memory leak when using `<Transition>` and `<KeepAlive>` together. Component instances may not be properly freed from memory when combining these features.
## Task Checklist
- [ ] Test memory behavior when using KeepAlive + Transition together
- [ ] Consider if transition animation is necessary with cached components
- [ ] Use browser DevTools Memory tab to verify no leak
- [ ] Keep Vue updated to get latest bug fixes
## The Problem
```vue
<template>
<!-- Known memory leak combination in some Vue versions -->
<Transition name="fade">
<KeepAlive>
<component :is="currentView" />
</KeepAlive>
</Transition>
</template>
```
When switching between components repeatedly:
- Component instances accumulate in memory
- References prevent garbage collection
- Memory usage grows with each switch
## Diagnosis
Use Chrome DevTools to detect the leak:
1. Open DevTools > Memory tab
2. Take heap snapshot
3. Switch between components 10+ times
4. Take another heap snapshot
5. Compare: look for growing VueComponent count
## Workarounds
### Option 1: Remove Transition if Not Essential
```vue
<template>
<!-- No memory leak without Transition -->
<KeepAlive :max="5">
<component :is="currentView" />
</KeepAlive>
</template>
```
### Option 2: Use CSS Animations Instead
```vue
<template>
<KeepAlive :max="5">
<component
:is="currentView"
:class="{ 'fade-enter': isTransitioning }"
/>
</KeepAlive>
</template>
<style>
.fade-enter {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
```
### Option 3: Use Strict Cache Limits
If you must use both, minimize impact with strict limits:
```vue
<template>
<Transition name="fade" mode="out-in">
<KeepAlive :max="3">
<component :is="currentView" />
</KeepAlive>
</Transition>
</template>
```
### Option 4: Key-Based Cache Invalidation
Force fresh instances when needed:
```vue
<script setup>
import { ref, computed } from 'vue'
const currentView = ref('Dashboard')
const cacheKey = ref(0)
function switchViewFresh(view) {
currentView.value = view
cacheKey.value++ // Force new instance
}
</script>
<template>
<Transition name="fade" mode="out-in">
<KeepAlive :max="3">
<component :is="currentView" :key="cacheKey" />
</KeepAlive>
</Transition>
</template>
```
## Keep Vue Updated
This is a known issue tracked in Vue's GitHub repository. Memory leak fixes are periodically released, so ensure you're on the latest Vue version:
```bash
npm update vue
```
## Key Points
1. **Known issue** - Memory leaks with KeepAlive + Transition are documented
2. **Test in DevTools** - Use Memory tab to verify your specific usage
3. **Consider alternatives** - CSS animations may work without the leak
4. **Set strict `max`** - Limit cache size to cap memory impact
5. **Keep Vue updated** - Bug fixes are released periodically
## Reference
- [GitHub Issue #9842: Memory leak with transition and keep-alive](https://github.com/vuejs/vue/issues/9842)
- [GitHub Issue #9840: Memory leak with transition and keep-alive](https://github.com/vuejs/vue/issues/9840)
- [Vue.js KeepAlive Documentation](https://vuejs.org/guide/built-ins/keep-alive.html)

View File

@@ -0,0 +1,156 @@
---
title: Register Lifecycle Hooks Synchronously During Setup
impact: HIGH
impactDescription: Asynchronously registered lifecycle hooks will never execute
type: capability
tags: [vue3, composition-api, lifecycle, onMounted, onUnmounted, async, setup]
---
# Register Lifecycle Hooks Synchronously During Setup
**Impact: HIGH** - Lifecycle hooks registered asynchronously (e.g., inside setTimeout, after await) will never be called because Vue cannot associate them with the component instance. This leads to silent failures where expected initialization or cleanup code never runs.
In Vue 3's Composition API, lifecycle hooks like `onMounted`, `onUnmounted`, `onUpdated`, etc. must be registered synchronously during component setup. The hook registration doesn't need to be lexically inside `setup()` or `<script setup>`, but the call stack must be synchronous and originate from within setup.
## Task Checklist
- [ ] Register all lifecycle hooks at the top level of setup() or `<script setup>`
- [ ] Never register hooks inside setTimeout, setInterval, or Promise callbacks
- [ ] When calling composables that use lifecycle hooks, call them synchronously
- [ ] Hooks CAN be in external functions if called synchronously from setup
**Incorrect:**
```javascript
// WRONG: Hook registered asynchronously - will NEVER execute
import { onMounted } from 'vue'
export default {
async setup() {
// After await, we're in a different call stack
const data = await fetchInitialData()
// This hook will NOT be registered!
onMounted(() => {
console.log('This will never run')
})
}
}
```
```javascript
// WRONG: Hook registered in setTimeout - will NEVER execute
import { onMounted } from 'vue'
export default {
setup() {
setTimeout(() => {
// This is asynchronous - hook won't be registered!
onMounted(() => {
initializeChart()
})
}, 100)
}
}
```
```javascript
// WRONG: Hook registered in Promise callback
import { onMounted } from 'vue'
export default {
setup() {
fetchConfig().then(() => {
// Asynchronous! This will silently fail
onMounted(() => {
applyConfig()
})
})
}
}
```
**Correct:**
```javascript
// CORRECT: Hook registered synchronously at top level
import { onMounted, ref } from 'vue'
export default {
setup() {
const data = ref(null)
// Register hook synchronously FIRST
onMounted(async () => {
// Async operations are fine INSIDE the hook
data.value = await fetchInitialData()
initializeChart()
})
return { data }
}
}
```
```vue
<!-- CORRECT: <script setup> - hooks at top level -->
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
const isReady = ref(false)
// These are synchronous during script setup execution
onMounted(() => {
isReady.value = true
})
onUnmounted(() => {
cleanup()
})
</script>
```
```javascript
// CORRECT: Hook in external function called synchronously from setup
import { onMounted, onUnmounted } from 'vue'
function useWindowResize(callback) {
// This is fine - it's called synchronously from setup
onMounted(() => {
window.addEventListener('resize', callback)
})
onUnmounted(() => {
window.removeEventListener('resize', callback)
})
}
export default {
setup() {
// Composable called synchronously - hooks will be registered
useWindowResize(handleResize)
}
}
```
## Multiple Hooks Are Allowed
```javascript
// CORRECT: You can register the same hook multiple times
import { onMounted } from 'vue'
export default {
setup() {
// Both will run, in order of registration
onMounted(() => {
initializeA()
})
onMounted(() => {
initializeB()
})
}
}
```
## Reference
- [Vue.js Lifecycle Hooks](https://vuejs.org/guide/essentials/lifecycle.html)
- [Composition API Lifecycle Hooks](https://vuejs.org/api/composition-api-lifecycle.html)

View File

@@ -0,0 +1,88 @@
---
title: mount() Returns Component Instance, Not App Instance
impact: MEDIUM
impactDescription: Using mount() return value for app configuration silently fails
type: capability
tags: [vue3, createApp, mount, api]
---
# mount() Returns Component Instance, Not App Instance
**Impact: MEDIUM** - The `.mount()` method returns the root component instance, not the application instance. Attempting to chain app configuration methods after mount() will fail or produce unexpected behavior.
This is a subtle API detail that catches developers who assume mount() returns the app for continued chaining.
## Task Checklist
- [ ] Never chain app configuration methods after mount()
- [ ] If you need both instances, store them separately
- [ ] Use the component instance for accessing root component state or methods
- [ ] Use the app instance for configuration, plugins, and global registration
**Incorrect:**
```javascript
import { createApp } from 'vue'
import App from './App.vue'
// WRONG: Assuming mount returns app instance
const app = createApp(App).mount('#app')
// This fails! app is actually the root component instance
app.use(router) // TypeError: app.use is not a function
app.config.errorHandler = fn // app.config is undefined
```
```javascript
// WRONG: Trying to save both in one line
const { app, component } = createApp(App).mount('#app') // Doesn't work this way
```
**Correct:**
```javascript
import { createApp } from 'vue'
import App from './App.vue'
// Store app instance separately
const app = createApp(App)
// Configure the app
app.use(router)
app.config.errorHandler = (err) => console.error(err)
// Store component instance if needed
const rootComponent = app.mount('#app')
// Now you have access to both:
// - app: the application instance (for config, plugins)
// - rootComponent: the root component instance (for state, methods)
```
```javascript
// If you only need the app configured and mounted (most common case):
createApp(App)
.use(router)
.use(pinia)
.mount('#app') // Return value (component instance) discarded - that's fine
```
## When You Need the Root Component Instance
```javascript
const app = createApp(App)
const vm = app.mount('#app')
// Access root component's exposed state/methods
console.log(vm.someExposedProperty)
vm.someExposedMethod()
// In Vue 3 with <script setup>, use defineExpose to expose:
// <script setup>
// import { ref } from 'vue'
// const count = ref(0)
// defineExpose({ count })
// </script>
```
## Reference
- [Vue.js - Mounting the App](https://vuejs.org/guide/essentials/application.html#mounting-the-app)
- [Vue.js Application API - mount()](https://vuejs.org/api/application.html#app-mount)

View File

@@ -0,0 +1,134 @@
---
title: Mouse Button Modifiers Represent Intent, Not Physical Buttons
impact: LOW
impactDescription: Mouse modifiers .left/.right/.middle may not match physical buttons on left-handed mice or other input devices
type: gotcha
tags: [vue3, events, mouse, accessibility, modifiers]
---
# Mouse Button Modifiers Represent Intent, Not Physical Buttons
**Impact: LOW** - Vue's mouse button modifiers (`.left`, `.right`, `.middle`) are named based on a typical right-handed mouse layout, but they actually represent "main", "secondary", and "auxiliary" pointing device triggers. This means they may not correspond to physical button positions on left-handed mice, trackpads, or other input devices.
## Task Checklist
- [ ] Understand that `.left` means "primary/main" action, not physical left button
- [ ] Understand that `.right` means "secondary" action (usually context menu)
- [ ] Consider accessibility when relying on specific mouse buttons
- [ ] Don't assume users have a traditional right-handed mouse
**Potentially Confusing:**
```html
<template>
<!-- Documentation says "left click only" but... -->
<div @click.left="handlePrimaryAction">
<!-- On left-handed mouse: fires on physical RIGHT button -->
<!-- On trackpad: fires on single-finger tap -->
<!-- On touch screen: fires on tap -->
</div>
</template>
```
**Clear Understanding:**
```html
<template>
<!-- Think of it as "primary action" -->
<div @click.left="handlePrimaryAction">
Primary action (main button)
</div>
<!-- Think of it as "secondary/context action" -->
<div @click.right="handleSecondaryAction">
Secondary action (context menu button)
</div>
<!-- Think of it as "auxiliary action" -->
<div @click.middle="handleAuxiliaryAction">
Auxiliary action (scroll wheel click)
</div>
</template>
```
## What the Modifiers Actually Mean
```javascript
// Vue modifier → MouseEvent.button value → Actual meaning
// .left → button === 0 → "Main button" (primary action)
// .right → button === 2 → "Secondary button" (context menu)
// .middle → button === 1 → "Auxiliary button" (middle click)
// The browser handles remapping for:
// - Left-handed mouse settings
// - Trackpad gestures
// - Touch devices
// - Stylus/pen input
```
## Device Behaviors
```html
<!-- How different devices trigger these modifiers -->
<!-- Traditional right-handed mouse -->
<!-- .left = physical left button -->
<!-- .right = physical right button -->
<!-- .middle = scroll wheel press -->
<!-- Left-handed mouse (swapped in OS settings) -->
<!-- .left = physical RIGHT button (remapped by OS) -->
<!-- .right = physical LEFT button (remapped by OS) -->
<!-- .middle = scroll wheel press -->
<!-- Trackpad -->
<!-- .left = single-finger tap/click -->
<!-- .right = two-finger tap/click (or corner click) -->
<!-- .middle = three-finger tap (if configured) -->
<!-- Touch screen -->
<!-- .left = tap -->
<!-- .right = long press (context menu) -->
<!-- .middle = typically not available -->
```
## Best Practice: Semantic Naming in Comments
```html
<template>
<!-- Use comments to clarify intent -->
<!-- Primary action (select item) -->
<div @click.left="selectItem">
<!-- Context menu action -->
<div @click.right.prevent="showContextMenu">
<!-- Open in new tab (auxiliary/middle click convention) -->
<a @click.middle="openInNewTab" href="...">
</template>
```
## Accessibility Considerations
```html
<template>
<!-- Don't require specific mouse buttons for essential actions -->
<!-- BETTER: Provide keyboard alternatives -->
<div
@click.left="select"
@click.right.prevent="showMenu"
@keydown.enter="select"
@keydown.space="select"
@contextmenu.prevent="showMenu"
tabindex="0"
role="button"
>
Accessible interactive element
</div>
</template>
```
## Reference
- [Vue.js Event Handling - Mouse Button Modifiers](https://vuejs.org/guide/essentials/event-handling.html#mouse-button-modifiers)
- [MDN - MouseEvent.button](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button)

View File

@@ -0,0 +1,115 @@
---
title: Use Multiple App Instances for Partial Page Control
impact: MEDIUM
impactDescription: Mounting single app to entire page when only controlling parts wastes resources and complicates SSR
type: efficiency
tags: [vue3, createApp, mount, ssr, progressive-enhancement, architecture]
---
# Use Multiple App Instances for Partial Page Control
**Impact: MEDIUM** - When Vue only controls specific parts of a page (common with server-rendered HTML or progressive enhancement), mounting a single large app instance to the entire page is inefficient and can complicate server-side rendering integration.
Vue's `createApp` API explicitly supports multiple application instances on the same page. Each instance has its own isolated scope for configuration and global assets, making this pattern safe and recommended.
## Task Checklist
- [ ] Assess whether Vue controls the entire page or just specific parts
- [ ] For partial control, create separate app instances for each Vue-managed section
- [ ] Each instance can have its own plugins, components, and configuration
- [ ] Consider shared state via external stores if instances need to communicate
**Incorrect:**
```javascript
// Server-rendered page with Vue only needed for a few interactive widgets
// WRONG: Mounting to entire page
// index.html (server-rendered)
// <body id="app">
// <header>...</header> <!-- static -->
// <nav>...</nav> <!-- static -->
// <div class="widget-1">...</div> <!-- needs Vue -->
// <main>...</main> <!-- static -->
// <div class="widget-2">...</div> <!-- needs Vue -->
// <footer>...</footer> <!-- static -->
// </body>
import { createApp } from 'vue'
import BigApp from './BigApp.vue'
// WRONG: Vue now controls entire page, including static content
createApp(BigApp).mount('#app')
```
**Correct:**
```javascript
// CORRECT: Mount separate instances to specific elements
import { createApp } from 'vue'
import SearchWidget from './widgets/SearchWidget.vue'
import CartWidget from './widgets/CartWidget.vue'
import { createPinia } from 'pinia'
// Shared store for cross-widget state
const pinia = createPinia()
// Widget 1: Search functionality
const searchApp = createApp(SearchWidget)
searchApp.use(pinia)
searchApp.mount('.widget-search')
// Widget 2: Shopping cart
const cartApp = createApp(CartWidget)
cartApp.use(pinia) // Same Pinia instance = shared state
cartApp.mount('.widget-cart')
// Rest of page remains server-rendered static HTML
```
## Benefits of Multiple Instances
```javascript
// 1. Isolated configuration per section
const adminApp = createApp(AdminPanel)
adminApp.config.errorHandler = adminErrorHandler
adminApp.use(adminOnlyPlugin)
adminApp.mount('#admin-panel')
const publicApp = createApp(PublicWidget)
publicApp.config.errorHandler = publicErrorHandler
// Different plugins, components, configuration
publicApp.mount('#public-widget')
// 2. Independent lifecycle
// Can unmount/remount sections independently
const app1 = createApp(Widget1).mount('#widget-1')
const app2 = createApp(Widget2).mount('#widget-2')
// Later, unmount just one widget
// app1.$destroy() in Vue 2, use app.unmount() for the app instance in Vue 3
```
## Shared State Between Instances
```javascript
// Option 1: Shared Pinia store
const pinia = createPinia()
createApp(App1).use(pinia).mount('#app1')
createApp(App2).use(pinia).mount('#app2')
// Both apps share the same Pinia stores
// Option 2: Shared reactive state module
// sharedState.js
import { reactive } from 'vue'
export const sharedState = reactive({
user: null,
cart: []
})
// Both apps import and use sharedState directly
```
## Reference
- [Vue.js - Multiple Application Instances](https://vuejs.org/guide/essentials/application.html#multiple-application-instances)
- [Vue.js Application API](https://vuejs.org/api/application.html)

View File

@@ -0,0 +1,141 @@
---
title: Never Use .passive and .prevent Together
impact: HIGH
impactDescription: Conflicting modifiers cause .prevent to be ignored and trigger browser warnings
type: gotcha
tags: [vue3, events, modifiers, scroll, touch, performance]
---
# Never Use .passive and .prevent Together
**Impact: HIGH** - The `.passive` modifier tells the browser you will NOT call `preventDefault()`, while `.prevent` does exactly that. Using them together causes `.prevent` to be ignored and triggers browser console warnings. This is a logical contradiction that leads to broken event handling.
## Task Checklist
- [ ] Never combine `.passive` and `.prevent` on the same event
- [ ] Use `.passive` for scroll/touch events where you want better performance
- [ ] Use `.prevent` when you need to stop the default browser action
- [ ] If you need conditional prevention, handle it in JavaScript without `.passive`
**Incorrect:**
```html
<!-- WRONG: Conflicting modifiers -->
<template>
<div @scroll.passive.prevent="handleScroll">
<!-- .prevent will be IGNORED -->
<!-- Browser shows warning -->
</div>
</template>
```
```html
<!-- WRONG: On touch events -->
<template>
<div @touchstart.passive.prevent="handleTouch">
<!-- Cannot prevent default - passive already promised not to -->
</div>
</template>
```
```html
<!-- WRONG: On wheel events -->
<template>
<div @wheel.passive.prevent="handleWheel">
<!-- Broken: will scroll anyway despite .prevent -->
</div>
</template>
```
**Correct:**
```html
<!-- CORRECT: Use .passive for performance (no prevention needed) -->
<template>
<div @scroll.passive="handleScroll">
<!-- Good for scroll tracking without blocking -->
</div>
</template>
```
```html
<!-- CORRECT: Use .prevent when you need to prevent default -->
<template>
<form @submit.prevent="handleSubmit">
<!-- Correctly prevents form submission -->
</form>
</template>
```
```html
<!-- CORRECT: For touch events where you need to prevent -->
<template>
<div @touchmove="handleTouchMove">
<!-- Handle prevention conditionally in JS -->
</div>
</template>
<script setup>
function handleTouchMove(event) {
if (shouldPreventScroll.value) {
event.preventDefault()
}
// ... handle touch
}
</script>
```
## Understanding .passive
```javascript
// .passive tells the browser:
// "I promise I won't call preventDefault()"
// This allows the browser to:
// 1. Start scrolling immediately without waiting for JS
// 2. Improve scroll performance, especially on mobile
// 3. Reduce jank and stuttering
// Equivalent to:
element.addEventListener('scroll', handler, { passive: true })
```
## When to Use .passive
```html
<!-- Good use cases for .passive -->
<!-- Scroll tracking analytics -->
<div @scroll.passive="trackScrollPosition">
<!-- Touch gesture detection (no prevention needed) -->
<div @touchmove.passive="detectGesture">
<!-- Wheel event monitoring -->
<div @wheel.passive="monitorWheel">
```
## When to Use .prevent (Without .passive)
```html
<!-- Good use cases for .prevent -->
<!-- Form submission -->
<form @submit.prevent="handleSubmit">
<!-- Link clicks with custom navigation -->
<a @click.prevent="navigate">
<!-- Preventing context menu -->
<div @contextmenu.prevent="showCustomMenu">
```
## Browser Warning
When you combine `.passive` and `.prevent`, the browser console shows:
```
[Intervention] Unable to preventDefault inside passive event listener
due to target being treated as passive.
```
## Reference
- [Vue.js Event Handling - Event Modifiers](https://vuejs.org/guide/essentials/event-handling.html#event-modifiers)
- [MDN - Improving scroll performance with passive listeners](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners)

View File

@@ -0,0 +1,136 @@
---
title: Never Use v-if and v-for on the Same Element
impact: HIGH
impactDescription: Causes confusing precedence issues and Vue 2 to 3 migration bugs
type: capability
tags: [vue3, v-if, v-for, conditional-rendering, list-rendering, eslint]
---
# Never Use v-if and v-for on the Same Element
**Impact: HIGH** - Using `v-if` and `v-for` on the same element creates ambiguous precedence that differs between Vue 2 and Vue 3. In Vue 2, `v-for` had higher precedence; in Vue 3, `v-if` has higher precedence. This breaking change causes subtle bugs during migration and makes code intent unclear.
The ESLint rule `vue/no-use-v-if-with-v-for` enforces this best practice.
## Task Checklist
- [ ] Never place v-if and v-for on the same element
- [ ] For filtering list items: use a computed property that filters the array
- [ ] For hiding entire list: wrap with `<template v-if>` around the v-for
- [ ] Enable eslint-plugin-vue rule `vue/no-use-v-if-with-v-for`
**Incorrect:**
```html
<!-- WRONG: v-if and v-for on same element - ambiguous precedence -->
<template>
<!-- Intent: show only active users -->
<li v-for="user in users" v-if="user.isActive" :key="user.id">
{{ user.name }}
</li>
</template>
```
```html
<!-- WRONG: Hiding entire list conditionally -->
<template>
<li v-for="user in users" v-if="shouldShowList" :key="user.id">
{{ user.name }}
</li>
</template>
```
```html
<!-- WRONG: Vue 3 precedence issue -->
<template>
<!-- In Vue 3, v-if runs FIRST, so 'user' is undefined! -->
<li v-for="user in users" v-if="user.isActive" :key="user.id">
{{ user.name }}
</li>
<!-- Error: Cannot read property 'isActive' of undefined -->
</template>
```
**Correct:**
```html
<!-- CORRECT: Filter with computed property -->
<template>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps(['users'])
const activeUsers = computed(() =>
props.users.filter(user => user.isActive)
)
</script>
```
```html
<!-- CORRECT: Wrap with <template v-if> for conditional list -->
<template>
<template v-if="shouldShowList">
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</template>
</template>
```
```html
<!-- CORRECT: v-if inside the loop (per-item condition) -->
<template>
<ul>
<template v-for="user in users" :key="user.id">
<li v-if="user.isActive">
{{ user.name }}
</li>
</template>
</ul>
</template>
```
## Vue 2 vs Vue 3 Precedence Change
```javascript
// Vue 2: v-for evaluated first
// <li v-for="user in users" v-if="user.isActive">
// Equivalent to: users.forEach(user => { if (user.isActive) render(user) })
// Vue 3: v-if evaluated first
// <li v-for="user in users" v-if="user.isActive">
// Equivalent to: if (user.isActive) users.forEach(user => render(user))
// Problem: 'user' doesn't exist yet when v-if runs!
```
## Why Computed Properties Are Better
```javascript
// Benefits of filtering via computed:
// 1. Clear separation of concerns (logic vs template)
// 2. Cached - only recalculates when dependencies change
// 3. Reusable - can be used elsewhere in component
// 4. Testable - can unit test the filtering logic
// 5. No ambiguity about intent
const activeUsers = computed(() =>
users.value.filter(u => u.isActive)
)
// Can add more complex filtering
const filteredUsers = computed(() =>
users.value
.filter(u => u.isActive)
.filter(u => u.role === selectedRole.value)
.sort((a, b) => a.name.localeCompare(b.name))
)
```
## Reference
- [Vue.js Style Guide - Avoid v-if with v-for](https://vuejs.org/style-guide/rules-essential.html#avoid-v-if-with-v-for)
- [Vue 3 Migration Guide - v-if vs v-for Precedence](https://v3-migration.vuejs.org/breaking-changes/v-if-v-for)
- [ESLint Plugin Vue - no-use-v-if-with-v-for](https://eslint.vuejs.org/rules/no-use-v-if-with-v-for)

View File

@@ -0,0 +1,162 @@
---
title: Avoid Excessive Component Abstraction in Large Lists
impact: MEDIUM
impactDescription: Each component instance has memory and render overhead - abstractions multiply this in lists
type: efficiency
tags: [vue3, performance, components, abstraction, lists, optimization]
---
# 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 Checklist
- [ ] 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
**Incorrect:**
```vue
<!-- 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 -->
```
**Correct:**
```vue
<!-- 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
```vue
<!-- 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
```javascript
// 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
```vue
<!-- 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 |
## Reference
- [Vue.js Performance - Avoid Unnecessary Component Abstractions](https://vuejs.org/guide/best-practices/performance.html#avoid-unnecessary-component-abstractions)

View File

@@ -0,0 +1,157 @@
---
title: Return Stable Object References from Computed Properties
impact: MEDIUM
impactDescription: Computed properties returning new objects trigger effects even when values haven't meaningfully changed
type: efficiency
tags: [vue3, computed, performance, reactivity, vue3.4]
---
# Return Stable Object References from Computed Properties
**Impact: MEDIUM** - In Vue 3.4+, computed properties only trigger effects when their value changes. However, if a computed returns a new object each time, Vue cannot detect that the values inside are the same. This causes unnecessary effect re-runs.
For primitive values, Vue 3.4+ handles this automatically. For objects, manually compare and return the previous value when nothing meaningful has changed.
## Task Checklist
- [ ] For computed properties returning primitives, Vue 3.4+ handles stability automatically
- [ ] For computed properties returning objects, compare with previous value and return old reference if unchanged
- [ ] Always perform the full computation before comparing (to track dependencies correctly)
- [ ] Consider if you really need to return an object, or if primitives would suffice
**Incorrect:**
```vue
<script setup>
import { ref, computed, watchEffect } from 'vue'
const count = ref(0)
// BAD: Returns new object every time, always triggers effects
const stats = computed(() => {
return {
isEven: count.value % 2 === 0,
doubleValue: count.value * 2
}
})
watchEffect(() => {
console.log('Stats changed:', stats.value)
// Logs on EVERY count change, even when isEven hasn't changed
// count: 0 -> 2 -> 4: isEven is always true, but effect runs each time
})
</script>
```
**Correct:**
```vue
<script setup>
import { ref, computed, watchEffect } from 'vue'
const count = ref(0)
// GOOD (Vue 3.4+): Primitive computed - automatic stability
const isEven = computed(() => count.value % 2 === 0)
watchEffect(() => {
console.log('isEven:', isEven.value)
// Only logs when isEven actually changes (0, 2, 4 won't re-trigger)
})
// GOOD (Vue 3.4+): Manual comparison for object returns
const stats = computed((oldValue) => {
// Step 1: Always compute the new value first (to track dependencies)
const newValue = {
isEven: count.value % 2 === 0,
category: count.value < 10 ? 'small' : 'large'
}
// Step 2: Compare with previous value
if (oldValue &&
oldValue.isEven === newValue.isEven &&
oldValue.category === newValue.category) {
return oldValue // Return old reference - no effect triggers
}
return newValue
})
watchEffect(() => {
console.log('Stats changed:', stats.value)
// Now only logs when isEven or category actually changes
})
</script>
```
## Primitive vs Object Computed Behavior (Vue 3.4+)
```javascript
import { ref, computed, watchEffect } from 'vue'
const count = ref(0)
// PRIMITIVE: Vue automatically detects value hasn't changed
const isEven = computed(() => count.value % 2 === 0)
watchEffect(() => console.log(isEven.value)) // true
count.value = 2 // isEven still true - NO log
count.value = 4 // isEven still true - NO log
count.value = 3 // isEven now false - logs: false
// OBJECT: New reference every time (without manual comparison)
const obj = computed(() => ({ isEven: count.value % 2 === 0 }))
watchEffect(() => console.log(obj.value)) // { isEven: true }
count.value = 2 // Logs again! New object reference
count.value = 4 // Logs again! New object reference
```
## Advanced: Deep Object Comparison
```javascript
import { ref, computed } from 'vue'
import { isEqual } from 'lodash-es' // For deep comparison
const filters = ref({ category: 'all', sortBy: 'date', page: 1 })
// For complex objects, use deep comparison
const activeFilters = computed((oldValue) => {
const newValue = {
...filters.value,
hasFilters: filters.value.category !== 'all' || filters.value.sortBy !== 'date'
}
// Deep compare for complex objects
if (oldValue && isEqual(oldValue, newValue)) {
return oldValue
}
return newValue
})
```
## Important: Always Compute Before Comparing
```javascript
// BAD: Early return prevents dependency tracking
const optimized = computed((oldValue) => {
if (oldValue && someCondition) {
return oldValue // Dependencies not tracked!
}
return computeExpensiveValue()
})
// GOOD: Compute first, then compare
const optimized = computed((oldValue) => {
const newValue = computeExpensiveValue() // Always track dependencies
if (oldValue && newValue === oldValue) {
return oldValue
}
return newValue
})
```
## Reference
- [Vue.js Performance - Computed Stability](https://vuejs.org/guide/best-practices/performance.html#computed-stability)
- [Vue.js Computed Properties](https://vuejs.org/guide/essentials/computed.html)

View File

@@ -0,0 +1,140 @@
---
title: Keep Props Stable to Minimize Child Re-renders
impact: HIGH
impactDescription: Passing changing props to list items causes ALL children to re-render unnecessarily
type: efficiency
tags: [vue3, performance, props, v-for, re-renders, optimization]
---
# Keep Props Stable to Minimize Child Re-renders
**Impact: HIGH** - When props passed to child components change, Vue must re-render those components. Passing derived values like `activeId` to every list item causes all items to re-render when activeId changes, even if only one item's active state actually changed.
Move comparison logic to the parent and pass the boolean result instead. This is one of the most impactful update performance optimizations in Vue.
## Task Checklist
- [ ] Avoid passing parent-level state that all children compare against (like `activeId`)
- [ ] Pre-compute derived boolean props in the parent (like `:active="item.id === activeId"`)
- [ ] Profile re-renders using Vue DevTools to identify prop stability issues
- [ ] Consider this pattern especially critical for large lists
**Incorrect:**
```vue
<template>
<!-- BAD: activeId changes -> ALL 100 ListItems re-render -->
<ListItem
v-for="item in list"
:key="item.id"
:id="item.id"
:active-id="activeId"
/>
</template>
<script setup>
import { ref } from 'vue'
const list = ref([/* 100 items */])
const activeId = ref(null)
// When activeId changes from 1 to 2:
// - ListItem 1 needs to re-render (was active, now not)
// - ListItem 2 needs to re-render (was not active, now active)
// - All other 98 ListItems ALSO re-render because activeId prop changed!
</script>
```
```vue
<!-- ListItem.vue - receives activeId and compares internally -->
<template>
<div :class="{ active: id === activeId }">
{{ id }}
</div>
</template>
<script setup>
defineProps({
id: Number,
activeId: Number // This prop changes for ALL items
})
</script>
```
**Correct:**
```vue
<template>
<!-- GOOD: Only items whose :active actually changed will re-render -->
<ListItem
v-for="item in list"
:key="item.id"
:id="item.id"
:active="item.id === activeId"
/>
</template>
<script setup>
import { ref } from 'vue'
const list = ref([/* 100 items */])
const activeId = ref(null)
// When activeId changes from 1 to 2:
// - ListItem 1: :active changed from true to false -> re-renders
// - ListItem 2: :active changed from false to true -> re-renders
// - All other 98 ListItems: :active is still false -> NO re-render!
</script>
```
```vue
<!-- ListItem.vue - receives pre-computed boolean -->
<template>
<div :class="{ active }">
{{ id }}
</div>
</template>
<script setup>
defineProps({
id: Number,
active: Boolean // This only changes for items that truly changed
})
</script>
```
## Common Patterns That Cause Prop Instability
```vue
<!-- BAD: Passing index that could shift -->
<Item
v-for="(item, index) in items"
:key="item.id"
:index="index"
:total="items.length" <!-- Changes when list changes -->
/>
<!-- BAD: Passing entire selection set -->
<Item
v-for="item in items"
:key="item.id"
:selected-ids="selectedIds" <!-- All items re-render on any selection -->
/>
<!-- GOOD: Pre-compute the boolean -->
<Item
v-for="item in items"
:key="item.id"
:selected="selectedIds.includes(item.id)"
/>
```
## Performance Impact Example
| Scenario | Props Changed | Components Re-rendered |
|----------|---------------|------------------------|
| 100 items, pass `activeId` | 100 | 100 (all) |
| 100 items, pass `:active` boolean | 2 | 2 (only changed) |
| 1000 items, pass `activeId` | 1000 | 1000 (all) |
| 1000 items, pass `:active` boolean | 2 | 2 (only changed) |
## Reference
- [Vue.js Performance - Props Stability](https://vuejs.org/guide/best-practices/performance.html#props-stability)

View File

@@ -0,0 +1,170 @@
---
title: Use SSR or SSG for Page Load Critical Applications
impact: HIGH
impactDescription: Client-side SPAs require JavaScript execution before content appears, severely impacting LCP and INP metrics
type: capability
tags: [vue3, performance, ssr, ssg, nuxt, page-load, architecture]
---
# Use SSR or SSG for Page Load Critical Applications
**Impact: HIGH** - Pure client-side SPAs must download, parse, and execute JavaScript before users see content. This significantly delays Largest Contentful Paint (LCP) and impacts Core Web Vitals. For page load-critical apps (marketing sites, e-commerce, content sites), use Server-Side Rendering (SSR) or Static Site Generation (SSG).
Choose your architecture based on your content's nature and page load requirements.
## Task Checklist
- [ ] Evaluate if page load performance is critical for your use case
- [ ] Choose SSR for dynamic content that changes per request
- [ ] Choose SSG for content that doesn't change frequently
- [ ] Consider hybrid approaches (SSG for marketing, SPA for app)
- [ ] Use Nuxt.js for streamlined SSR/SSG with Vue
## Architecture Decision Guide
| Content Type | Changes | Best Approach | Example |
|--------------|---------|---------------|---------|
| Marketing pages | Rarely | SSG | Landing pages, docs |
| Blog/content | On publish | SSG with regeneration | Blog, documentation |
| E-commerce catalog | Hourly/daily | SSG + ISR | Product listings |
| User dashboard | Per request | SPA (ok) | Admin panels |
| Social feed | Real-time | SSR or SPA | News feed |
| Authenticated app | Per user | SPA (ok) | Internal tools |
**Incorrect:**
```javascript
// BAD: Pure client-side SPA for a marketing site
// Users see blank page until JS loads and executes
// main.js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app') // Nothing visible until this runs
// index.html - Users see empty #app until JS executes
// <div id="app"></div>
```
**Correct:**
```javascript
// GOOD: Use Nuxt.js for SSR/SSG
// nuxt.config.ts
export default defineNuxtConfig({
// SSG: Pre-render at build time (best for static content)
ssr: true,
nitro: {
prerender: {
routes: ['/', '/about', '/pricing', '/blog']
}
}
})
// Or full SSR for dynamic content
export default defineNuxtConfig({
ssr: true // Server renders on each request
})
```
```vue
<!-- pages/index.vue - Works with both SSR and SSG -->
<template>
<div>
<HeroSection />
<FeatureList :features="features" />
<PricingTable />
</div>
</template>
<script setup>
// Data fetched at build time (SSG) or request time (SSR)
const { data: features } = await useFetch('/api/features')
</script>
```
## Hybrid Approach: SSG Marketing + SPA App
```javascript
// nuxt.config.ts - Hybrid rendering
export default defineNuxtConfig({
routeRules: {
// Static pages - pre-rendered at build
'/': { prerender: true },
'/about': { prerender: true },
'/pricing': { prerender: true },
'/blog/**': { prerender: true },
// Dynamic app sections - client-side only
'/dashboard/**': { ssr: false },
'/app/**': { ssr: false },
// API routes - server-side
'/api/**': { cors: true }
}
})
```
## Manual SSR with Vue (without Nuxt)
```javascript
// server.js - Express with Vue SSR
import express from 'express'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import App from './App.vue'
const app = express()
app.get('*', async (req, res) => {
const vueApp = createSSRApp(App)
const html = await renderToString(vueApp)
res.send(`
<!DOCTYPE html>
<html>
<head><title>My App</title></head>
<body>
<div id="app">${html}</div>
<script type="module" src="/client.js"></script>
</body>
</html>
`)
})
```
## Performance Impact
| Approach | Time to First Byte | LCP | JavaScript Required |
|----------|-------------------|-----|---------------------|
| Client SPA | Fast | Slow (waits for JS) | Yes, before content |
| SSR | Slower | Fast (HTML ready) | No, for initial view |
| SSG | Fast (CDN) | Fast (HTML ready) | No, for initial view |
## When Client-Side SPA Is Acceptable
- Internal tools and dashboards (users expect loading)
- Authenticated applications (initial load happens once)
- Real-time collaborative apps (WebSocket-heavy)
- PWAs where offline-first is the priority
- Complex interactive applications (Figma-like)
## Static Site Generation with Vite
```javascript
// vite.config.js - Using vite-ssg for SSG
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
ssgOptions: {
script: 'async',
formatting: 'minify'
}
})
```
## Reference
- [Vue.js Performance - Page Load Optimizations](https://vuejs.org/guide/best-practices/performance.html#page-load-optimizations)
- [Vue.js SSR Guide](https://vuejs.org/guide/scaling-up/ssr.html)
- [Nuxt.js Documentation](https://nuxt.com/)

View File

@@ -0,0 +1,187 @@
---
title: Use v-once and v-memo to Skip Unnecessary Updates
impact: MEDIUM
impactDescription: v-once skips all future updates for static content; v-memo conditionally memoizes subtrees
type: efficiency
tags: [vue3, performance, v-once, v-memo, optimization, directives]
---
# 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 Checklist
- [ ] Apply `v-once` to elements that use runtime data but never need updating
- [ ] Apply `v-memo` to 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
**Incorrect:**
```vue
<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>
```
**Correct:**
```vue
<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
**Incorrect:**
```vue
<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>
```
**Correct:**
```vue
<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
```vue
<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
```vue
<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
```vue
<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
```vue
<script setup>
import { onUpdated } from 'vue'
// This won't fire if v-memo prevents update
onUpdated(() => {
console.log('Component updated')
})
</script>
```
## Reference
- [Vue.js v-once Directive](https://vuejs.org/api/built-in-directives.html#v-once)
- [Vue.js v-memo Directive](https://vuejs.org/api/built-in-directives.html#v-memo)
- [Vue.js Performance - Update Optimizations](https://vuejs.org/guide/best-practices/performance.html#update-optimizations)

View File

@@ -0,0 +1,192 @@
---
title: Virtualize Large Lists to Avoid DOM Overload
impact: HIGH
impactDescription: Rendering thousands of list items creates excessive DOM nodes, causing slow renders and high memory usage
type: efficiency
tags: [vue3, performance, virtual-list, large-data, dom, optimization]
---
# 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 Checklist
- [ ] Identify lists that render more than 50-100 items
- [ ] Install a virtualization library (vue-virtual-scroller, @tanstack/vue-virtual)
- [ ] Replace standard `v-for` with 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 |
**Incorrect:**
```vue
<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>
```
**Correct:**
```vue
<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
```vue
<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
```vue
<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
## Reference
- [Vue.js Performance - Virtualize Large Lists](https://vuejs.org/guide/best-practices/performance.html#virtualize-large-lists)
- [vue-virtual-scroller Documentation](https://github.com/Akryum/vue-virtual-scroller)
- [TanStack Virtual](https://tanstack.com/virtual/latest)

View File

@@ -0,0 +1,120 @@
# Prefer provide/inject Over Global Properties in Plugins
## Rule
When creating Vue plugins, prefer using `app.provide()` to make plugin functionality available to components instead of attaching properties to `app.config.globalProperties`.
## Why This Matters
1. **globalProperties don't work in setup()**: Properties attached to `globalProperties` are only accessible via `this` in Options API. They are NOT available in the Composition API's `setup()` function.
2. **Type safety**: `provide/inject` integrates better with TypeScript and requires less type augmentation boilerplate.
3. **Testability**: Injected dependencies are easier to mock in tests compared to global properties.
4. **Code clarity**: Explicit `inject()` calls make dependencies visible, while global properties can appear "magic".
5. **Scoping**: `provide/inject` follows Vue's component hierarchy, making it easier to provide different values to different parts of your app.
## Bad Practice
```typescript
// plugins/i18n.ts
export default {
install(app, options) {
// Attaching to globalProperties - only works with Options API
app.config.globalProperties.$translate = (key: string) => {
return key.split('.').reduce((o, i) => o?.[i], options)
}
}
}
// In component - requires type augmentation for TypeScript
// Also DOES NOT work in <script setup>
export default {
mounted() {
console.log(this.$translate('greeting.hello'))
}
}
```
## Good Practice
```typescript
// plugins/i18n.ts
import type { InjectionKey, App } from 'vue'
export interface I18nOptions {
[key: string]: string | I18nOptions
}
export interface I18n {
translate: (key: string) => string
options: I18nOptions
}
export const i18nKey: InjectionKey<I18n> = Symbol('i18n')
export default {
install(app: App, options: I18nOptions) {
const translate = (key: string): string => {
return key.split('.').reduce((o, i) => o?.[i], options) as string ?? key
}
// Use provide for Composition API compatibility
app.provide(i18nKey, { translate, options })
}
}
// In component - works in setup() and has full type safety
<script setup lang="ts">
import { inject } from 'vue'
import { i18nKey } from '@/plugins/i18n'
const i18n = inject(i18nKey)
console.log(i18n?.translate('greeting.hello'))
</script>
```
## Hybrid Approach
If you must support both APIs (e.g., for backwards compatibility), provide both:
```typescript
export default {
install(app: App, options: I18nOptions) {
const i18n = {
translate: (key: string) => /* ... */
}
// For Composition API
app.provide(i18nKey, i18n)
// For Options API (use sparingly)
app.config.globalProperties.$i18n = i18n
}
}
```
## TypeScript Type Augmentation (if using globalProperties)
If you must use globalProperties, you need proper type augmentation:
```typescript
// types/vue.d.ts
export {}
declare module 'vue' {
interface ComponentCustomProperties {
$translate: (key: string) => string
}
}
```
**Important**: The file MUST contain `export {}` or another top-level export/import. Without it, the augmentation will OVERWRITE types instead of augmenting them.
## References
- [Vue.js Plugins Documentation](https://vuejs.org/guide/reusability/plugins.html)
- [Vue.js Provide/Inject](https://vuejs.org/guide/components/provide-inject.html)
- [TypeScript with Options API](https://vuejs.org/guide/typescript/options-api.html)

View File

@@ -0,0 +1,206 @@
# Plugin Structure: Install Method Requirements
## Rule
A Vue plugin must be either an object with an `install()` method, or a function that serves as the install function. The install function receives the app instance and optional user-provided options.
## Why This Matters
1. **API contract**: Vue's `app.use()` expects a specific interface. Incorrect structure causes silent failures or errors.
2. **Options passing**: The install method receives options that users pass to `app.use()`, enabling plugin configuration.
3. **App access**: The install method receives the app instance, providing access to `app.component()`, `app.directive()`, `app.provide()`, etc.
## Plugin Structures
### Object with install method (recommended)
```typescript
// plugins/myPlugin.ts
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 })
}
}
export default myPlugin
// Usage
app.use(myPlugin, { prefix: 'custom', debug: true })
```
### Function as install (alternative)
```typescript
// plugins/simplePlugin.ts
import type { App } from 'vue'
function simplePlugin(app: App, options?: { message: string }) {
app.config.globalProperties.$greet = () => {
return options?.message ?? 'Hello!'
}
}
export default simplePlugin
// Usage
app.use(simplePlugin, { message: 'Welcome!' })
```
### Factory function pattern (most flexible)
```typescript
// plugins/configuredPlugin.ts
import type { App, Plugin } from 'vue'
interface I18nOptions {
locale: string
messages: Record<string, Record<string, string>>
fallbackLocale?: string
}
export function createI18n(options: I18nOptions): Plugin {
return {
install(app: App) {
// Options are captured in closure - no need to pass through app.use()
const { locale, messages, fallbackLocale = 'en' } = options
const translate = (key: string): string => {
return messages[locale]?.[key]
?? messages[fallbackLocale]?.[key]
?? key
}
app.provide('i18n', { translate, locale })
}
}
}
// Usage - options passed to factory, not app.use()
const i18n = createI18n({
locale: 'fr',
messages: {
en: { hello: 'Hello' },
fr: { hello: 'Bonjour' }
}
})
app.use(i18n) // No second argument needed
```
## Common Plugin Capabilities
```typescript
const fullFeaturedPlugin = {
install(app: App, options: PluginOptions) {
// 1. Register global components
app.component('MyButton', MyButtonComponent)
app.component('MyInput', MyInputComponent)
// 2. Register global directives
app.directive('focus', focusDirective)
// 3. Provide injectable values (recommended)
app.provide('pluginConfig', options)
// 4. Add global properties (use sparingly)
app.config.globalProperties.$myHelper = helperFunction
// 5. Add global mixins (avoid if possible)
app.mixin({
created() {
// Runs for every component
}
})
// 6. Custom error handling
app.config.errorHandler = (err, vm, info) => {
// Handle errors
}
}
}
```
## TypeScript: Plugin Type
Use the `Plugin` type for proper typing:
```typescript
import type { App, Plugin } from 'vue'
// With options type parameter
interface MyOptions {
apiKey: string
}
const myPlugin: Plugin<[MyOptions]> = {
install(app: App, options: MyOptions) {
// options is typed as MyOptions
}
}
// Without options
const simplePlugin: Plugin = {
install(app: App) {
// No options expected
}
}
```
## Common Mistakes
### Missing install method
```typescript
// BAD - This is just an object, not a plugin
const notAPlugin = {
doSomething() { /* ... */ }
}
app.use(notAPlugin) // Error or silent failure
// GOOD
const actualPlugin = {
install(app) {
app.provide('service', { doSomething() { /* ... */ } })
}
}
```
### Forgetting to use the app parameter
```typescript
// BAD - Does nothing
const uselessPlugin = {
install(app, options) {
const service = createService(options)
// Forgot to register anything with app!
}
}
// GOOD
const usefulPlugin = {
install(app, options) {
const service = createService(options)
app.provide('service', service) // Actually makes it available
}
}
```
## References
- [Vue.js Plugins Documentation](https://vuejs.org/guide/reusability/plugins.html)
- [Vue.js Application API](https://vuejs.org/api/application.html)

View File

@@ -0,0 +1,165 @@
# Use Symbol Injection Keys in Plugins
## Rule
When using `provide/inject` in Vue plugins, use Symbol keys (preferably with `InjectionKey<T>` for TypeScript) instead of string keys to prevent naming collisions and ensure type safety.
## Why This Matters
1. **Collision prevention**: String keys like `'i18n'` or `'api'` can easily collide between multiple plugins or different parts of your application.
2. **Type safety**: TypeScript's `InjectionKey<T>` provides automatic type inference when using `inject()`.
3. **Refactoring safety**: Symbols are unique, so renaming is safe and explicit.
4. **Debugging**: Symbols can have descriptive names for debugging while remaining unique.
## Bad Practice
```typescript
// plugin.ts
export default {
install(app) {
// String key - can collide with other plugins!
app.provide('http', axios)
app.provide('config', appConfig)
}
}
// component.vue
const http = inject('http') // Type is unknown
const config = inject('config') // Type is unknown
// Another plugin accidentally uses the same key
otherPlugin.install = (app) => {
app.provide('http', differentHttpClient) // COLLISION! Overwrites first
}
```
## Good Practice
```typescript
// plugins/keys.ts
import type { InjectionKey } from 'vue'
import type { AxiosInstance } from 'axios'
export interface AppConfig {
apiUrl: string
timeout: number
}
// Typed injection keys - unique and type-safe
export const httpKey: InjectionKey<AxiosInstance> = Symbol('http')
export const configKey: InjectionKey<AppConfig> = Symbol('appConfig')
// plugin.ts
import { httpKey, configKey } from './keys'
export default {
install(app) {
app.provide(httpKey, axios)
app.provide(configKey, { apiUrl: '/api', timeout: 5000 })
}
}
// component.vue
<script setup lang="ts">
import { inject } from 'vue'
import { httpKey, configKey } from '@/plugins/keys'
// Fully typed! No 'unknown' type
const http = inject(httpKey) // Type: AxiosInstance | undefined
const config = inject(configKey) // Type: AppConfig | undefined
</script>
```
## Providing Default Values with Type Safety
```typescript
// With InjectionKey, default values are type-checked
const config = inject(configKey, {
apiUrl: '/default-api',
timeout: 3000
})
// Type: AppConfig (not undefined because default provided)
// Type error if default doesn't match!
const config = inject(configKey, {
apiUrl: '/api'
// Missing 'timeout' - TypeScript error!
})
```
## Organizing Injection Keys
For larger applications, organize keys by domain:
```typescript
// injection-keys/index.ts
export * from './auth'
export * from './i18n'
export * from './http'
// injection-keys/auth.ts
import type { InjectionKey } from 'vue'
export interface AuthService {
login: (credentials: Credentials) => Promise<User>
logout: () => Promise<void>
currentUser: Ref<User | null>
}
export const authKey: InjectionKey<AuthService> = Symbol('auth')
// injection-keys/i18n.ts
export interface I18n {
t: (key: string, params?: Record<string, string>) => string
locale: Ref<string>
}
export const i18nKey: InjectionKey<I18n> = Symbol('i18n')
```
## Creating a useInject Helper
For cleaner component code, create typed composables:
```typescript
// composables/useAuth.ts
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
}
// component.vue
<script setup>
import { useAuth } from '@/composables/useAuth'
const auth = useAuth() // Type: AuthService (not undefined)
await auth.login(credentials)
</script>
```
## When String Keys Are Acceptable
1. **Internal plugin use**: If both provide and inject are in the same plugin file
2. **Simple applications**: Very small apps with no collision risk
3. **Dynamic keys**: When the key name must be computed at runtime
Even then, consider using a namespaced string:
```typescript
// Better than plain 'config'
app.provide('myPlugin:config', config)
```
## References
- [Vue.js Provide/Inject with Symbol Keys](https://vuejs.org/guide/components/provide-inject.html#working-with-symbol-keys)
- [Vue.js TypeScript Support for Inject](https://vuejs.org/guide/typescript/composition-api.html#typing-provide-inject)

View File

@@ -0,0 +1,157 @@
# Proper TypeScript Type Augmentation for Plugins
## Rule
When creating Vue plugins that add global properties, you MUST properly augment TypeScript types. The augmentation file MUST contain at least one top-level `import` or `export` statement to be treated as a module.
## Why This Matters
1. **Without module syntax, types are overwritten**: If your augmentation file isn't a module, it will OVERWRITE Vue's types instead of augmenting them, breaking type checking for the entire application.
2. **Type safety**: Proper augmentation enables autocomplete and type checking for plugin-provided properties.
3. **IDE support**: Developers get proper IntelliSense for global properties like `this.$translate`.
4. **Error prevention**: Catch typos and incorrect usage at compile time rather than runtime.
## Critical Rule: Module Syntax Required
```typescript
// BAD - This OVERWRITES Vue types instead of augmenting!
// types/vue.d.ts
declare module 'vue' {
interface ComponentCustomProperties {
$translate: (key: string) => string
}
}
// GOOD - The export {} makes this a module, so it AUGMENTS types
// types/vue.d.ts
export {} // This line is CRITICAL!
declare module 'vue' {
interface ComponentCustomProperties {
$translate: (key: string) => string
}
}
```
## Complete Plugin Type Augmentation Example
```typescript
// plugins/i18n.ts
import type { App, InjectionKey } from 'vue'
export interface I18nOptions {
locale: string
messages: Record<string, Record<string, string>>
}
export interface I18nInstance {
translate: (key: string) => string
locale: string
}
export const i18nInjectionKey: InjectionKey<I18nInstance> = Symbol('i18n')
export function createI18n(options: I18nOptions) {
const i18n: I18nInstance = {
translate(key: string) {
return options.messages[options.locale]?.[key] ?? key
},
locale: options.locale
}
return {
install(app: App) {
// For Composition API
app.provide(i18nInjectionKey, i18n)
// For Options API / templates
app.config.globalProperties.$t = i18n.translate
app.config.globalProperties.$i18n = i18n
}
}
}
// types/i18n.d.ts (or in the same file after export)
export {}
declare module 'vue' {
interface ComponentCustomProperties {
$t: (key: string) => string
$i18n: I18nInstance
}
}
```
## Alternative: Augment @vue/runtime-core
Some plugins augment `@vue/runtime-core` instead of `vue`:
```typescript
// types/global.d.ts
export {}
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$myPlugin: MyPluginInstance
}
}
```
Both approaches work, but `'vue'` is more common in application code.
## Ensure tsconfig.json Includes the Declaration File
```json
{
"compilerOptions": {
// ...
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"types/**/*.d.ts" // Include your declaration files
]
}
```
## For Library Authors: package.json Types Field
If publishing a plugin as a package:
```json
{
"name": "my-vue-plugin",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/index.mjs"
}
}
}
```
## Common Errors and Solutions
### Error: Property '$xyz' does not exist on type
1. Check that your `.d.ts` file has `export {}` or an import statement
2. Verify the file is included in `tsconfig.json`
3. Restart your TypeScript language server (VS Code: Cmd+Shift+P > "Restart TS Server")
### Error: Types work in some components but not others
This often happens when using Vetur instead of Volar. If you're on Vue 3, switch to Volar (Vue - Official extension).
### Error in Options API but not Composition API
Global properties on `this` require proper augmentation of `ComponentCustomProperties`. The Composition API uses `inject()` which is typed separately.
## References
- [Vue.js TypeScript with Options API](https://vuejs.org/guide/typescript/options-api.html)
- [TypeScript Module Augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation)
- [Vue.js Plugins Documentation](https://vuejs.org/guide/reusability/plugins.html)

View File

@@ -0,0 +1,137 @@
---
title: Prefer Local Component Registration Over Global
impact: MEDIUM
impactDescription: Global registration prevents tree-shaking and creates implicit dependencies
type: best-practice
tags: [vue3, component-registration, tree-shaking, performance, maintainability]
---
# Prefer Local Component Registration Over Global
**Impact: MEDIUM** - Global registration prevents build tools from removing unused components (tree-shaking), increasing bundle size. It also creates implicit dependencies similar to global variables, making it difficult to trace where components are used and locate their implementations in large applications.
Use local registration for better maintainability, smaller bundles, and explicit dependency relationships.
## Task Checklist
- [ ] Use local registration (import in each component) by default
- [ ] Reserve global registration only for truly ubiquitous components (icons, buttons, layouts)
- [ ] When using `<script setup>`, simply import components - no explicit registration needed
- [ ] Review existing global components and migrate to local registration where possible
**Incorrect:**
```javascript
// main.js - registering many components globally
import { createApp } from 'vue'
import App from './App.vue'
import Card from './components/Card.vue'
import Modal from './components/Modal.vue'
import Table from './components/Table.vue'
import Pagination from './components/Pagination.vue'
import UserAvatar from './components/UserAvatar.vue'
import SearchBar from './components/SearchBar.vue'
const app = createApp(App)
// WRONG: All these components are now in the bundle even if unused
app.component('Card', Card)
app.component('Modal', Modal)
app.component('Table', Table)
app.component('Pagination', Pagination)
app.component('UserAvatar', UserAvatar)
app.component('SearchBar', SearchBar)
app.mount('#app')
```
```vue
<!-- SomePage.vue - uses only Card, but all components are bundled -->
<template>
<Card>
<p>Content</p>
</Card>
</template>
<script setup>
// No imports - relying on global registration
// This makes dependencies invisible and hurts tree-shaking
</script>
```
**Correct:**
```javascript
// main.js - only register truly universal components globally
import { createApp } from 'vue'
import App from './App.vue'
import BaseIcon from './components/BaseIcon.vue'
import BaseButton from './components/BaseButton.vue'
const app = createApp(App)
// Only truly ubiquitous base components
app.component('BaseIcon', BaseIcon)
app.component('BaseButton', BaseButton)
app.mount('#app')
```
```vue
<!-- SomePage.vue - explicit local imports -->
<script setup>
// CORRECT: Explicit imports enable tree-shaking
// Only Card is included in the bundle for this component
import Card from '@/components/Card.vue'
import UserAvatar from '@/components/UserAvatar.vue'
</script>
<template>
<Card>
<UserAvatar :user="currentUser" />
<p>Content</p>
</Card>
</template>
```
```vue
<!-- Options API local registration -->
<script>
import Card from '@/components/Card.vue'
import UserAvatar from '@/components/UserAvatar.vue'
export default {
components: {
Card,
UserAvatar
}
}
</script>
```
## When Global Registration IS Appropriate
```javascript
// Truly universal base components used across the entire app
import BaseIcon from './components/BaseIcon.vue'
import BaseButton from './components/BaseButton.vue'
import BaseInput from './components/BaseInput.vue'
// Third-party component libraries with controlled scope
import { Button, Input } from 'some-ui-library'
app.component('BaseIcon', BaseIcon)
app.component('BaseButton', BaseButton)
app.component('BaseInput', BaseInput)
```
## Benefits of Local Registration
| Aspect | Global | Local |
|--------|--------|-------|
| Tree-shaking | Not possible | Full support |
| Dependency tracking | Implicit/hidden | Explicit imports |
| Component location | Hard to find | Follow import path |
| Bundle size | All registered components | Only used components |
| Refactoring | Risk of breaking unknown usages | Clear dependency graph |
## Reference
- [Vue.js Component Registration](https://vuejs.org/guide/components/registration.html)

View File

@@ -0,0 +1,216 @@
---
title: Prefer Props and Emit Over Component Refs
impact: MEDIUM
impactDescription: Component refs create tight coupling and break component abstraction
type: best-practice
tags: [vue3, component-refs, props, emit, component-design, architecture]
---
# Prefer Props and Emit Over Component Refs
**Impact: MEDIUM** - Using template refs to access child component internals creates tight coupling between parent and child. This makes components harder to maintain, refactor, and reuse. Props and emit provide a cleaner contract-based API that preserves component encapsulation.
Component refs should be reserved for imperative actions (focus, scroll, animations) that can't be expressed declaratively.
## Task Checklist
- [ ] Use props for passing data down to children
- [ ] Use emit for communicating events up to parents
- [ ] Only use component refs for imperative DOM operations
- [ ] If using refs, expose a minimal, documented API via defineExpose
- [ ] Consider if the interaction can be expressed declaratively
**Incorrect:**
```vue
<!-- ParentComponent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import UserForm from './UserForm.vue'
const formRef = ref(null)
// WRONG: Reaching into child's internals
function submitForm() {
// Tight coupling - parent knows child's internal structure
if (formRef.value.isValid) {
const data = formRef.value.formData
formRef.value.setSubmitting(true)
api.submit(data).then(() => {
formRef.value.setSubmitting(false)
formRef.value.reset()
})
}
}
// WRONG: Parent managing child's state
function prefillForm(userData) {
formRef.value.formData.name = userData.name
formRef.value.formData.email = userData.email
}
</script>
<template>
<UserForm ref="formRef" />
<button @click="submitForm">Submit</button>
</template>
```
```vue
<!-- UserForm.vue - exposing too much -->
<script setup>
import { ref, reactive } from 'vue'
const formData = reactive({ name: '', email: '' })
const isValid = ref(false)
const isSubmitting = ref(false)
function setSubmitting(value) {
isSubmitting.value = value
}
function reset() {
formData.name = ''
formData.email = ''
}
// WRONG: Exposing internal state details
defineExpose({
formData,
isValid,
isSubmitting,
setSubmitting,
reset
})
</script>
```
**Correct:**
```vue
<!-- ParentComponent.vue -->
<script setup>
import { ref } from 'vue'
import UserForm from './UserForm.vue'
const initialData = ref({ name: '', email: '' })
const isSubmitting = ref(false)
// CORRECT: Child communicates via events
function handleSubmit(formData) {
isSubmitting.value = true
api.submit(formData).finally(() => {
isSubmitting.value = false
})
}
function handleValidChange(isValid) {
console.log('Form validity:', isValid)
}
</script>
<template>
<!-- CORRECT: Props down, events up -->
<UserForm
:initial-data="initialData"
:submitting="isSubmitting"
@submit="handleSubmit"
@valid-change="handleValidChange"
/>
</template>
```
```vue
<!-- UserForm.vue - clean props/emit interface -->
<script setup>
import { reactive, computed, watch } from 'vue'
const props = defineProps({
initialData: { type: Object, default: () => ({}) },
submitting: { type: Boolean, default: false }
})
const emit = defineEmits(['submit', 'valid-change'])
const formData = reactive({ ...props.initialData })
const isValid = computed(() => {
return formData.name.length > 0 && formData.email.includes('@')
})
watch(isValid, (valid) => {
emit('valid-change', valid)
})
function handleSubmit() {
if (isValid.value) {
emit('submit', { ...formData })
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="formData.name" :disabled="submitting" />
<input v-model="formData.email" :disabled="submitting" />
<button type="submit" :disabled="!isValid || submitting">
{{ submitting ? 'Submitting...' : 'Submit' }}
</button>
</form>
</template>
```
## When Component Refs ARE Appropriate
```vue
<!-- CORRECT: Refs for imperative DOM operations -->
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const inputRef = ref(null)
// Imperative focus action - good use of refs
function focusInput() {
inputRef.value?.focus()
}
</script>
<template>
<CustomInput ref="inputRef" v-model="text" />
<button @click="focusInput">Focus Input</button>
</template>
```
```vue
<!-- CustomInput.vue - minimal imperative API -->
<script setup>
import { ref } from 'vue'
const inputEl = ref(null)
// Only expose imperative methods
defineExpose({
focus: () => inputEl.value?.focus(),
blur: () => inputEl.value?.blur(),
select: () => inputEl.value?.select()
})
</script>
<template>
<input ref="inputEl" v-bind="$attrs" />
</template>
```
## Summary
| Use Case | Approach |
|----------|----------|
| Pass data to child | Props |
| Child notifies parent | Emit events |
| Two-way binding | v-model (props + emit) |
| Focus, scroll, animate | Component ref with minimal expose |
| Access child internal state | Refactor to use props/emit |
## Reference
- [Vue.js Component Basics - Props](https://vuejs.org/guide/components/props.html)
- [Vue.js Component Events](https://vuejs.org/guide/components/events.html)
- [Vue.js Template Refs - Ref on Component](https://vuejs.org/guide/essentials/template-refs.html#ref-on-component)

View File

@@ -0,0 +1,82 @@
---
title: Prefer ref() Over reactive() for Consistency
impact: MEDIUM
impactDescription: Using ref() as default avoids reactive() gotchas and provides consistent patterns
type: efficiency
tags: [vue3, reactivity, ref, reactive, composition-api, best-practice]
---
# Prefer ref() Over reactive() for Consistency
**Impact: MEDIUM** - The Vue documentation recommends using `ref()` as the primary API for declaring reactive state. This avoids several `reactive()` gotchas and provides a consistent pattern across your codebase.
While both `ref()` and `reactive()` create reactive state, `reactive()` has several limitations: it only works with objects (not primitives), cannot be reassigned, and loses reactivity when destructured. Using `ref()` consistently means one pattern to remember.
## Task Checklist
- [ ] Use `ref()` as the default for all reactive state
- [ ] Only use `reactive()` when you have a specific reason (e.g., group of related state)
- [ ] Be consistent within a codebase - pick one approach and stick with it
- [ ] Remember: `.value` is the price for avoiding `reactive()` gotchas
**Incorrect:**
```javascript
import { reactive } from 'vue'
// reactive() has multiple gotchas:
// 1. Cannot use with primitives
const count = reactive(0) // Won't work - not reactive
// 2. Cannot reassign the entire object
let state = reactive({ items: [] })
state = reactive({ items: [1, 2, 3] }) // Loses reactivity!
// 3. Destructuring breaks reactivity
const { items } = state // items is not reactive
// 4. Passing to functions can lose reactivity
someFunction(state.items) // May lose reactivity depending on usage
```
**Correct:**
```javascript
import { ref } from 'vue'
// ref() works universally:
// 1. Works with primitives
const count = ref(0)
count.value++ // Works!
// 2. Can reassign the entire object
const state = ref({ items: [] })
state.value = { items: [1, 2, 3] } // Reactivity preserved!
// 3. No destructuring issues (you work with .value)
const items = state.value.items // If you need just the value
// 4. Passing refs is explicit
someFunction(state) // Pass the ref
someFunction(state.value) // Or pass the value explicitly
```
```javascript
// When reactive() makes sense: grouping related state
import { reactive, toRefs } from 'vue'
// Acceptable use case: form state with many related fields
const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: ''
})
// But always use toRefs() if you need to destructure
const { username, email } = toRefs(form)
```
## Reference
- [Vue.js Reactivity Fundamentals](https://vuejs.org/guide/essentials/reactivity-fundamentals.html)
- [Vue.js Composition API FAQ](https://vuejs.org/guide/extras/composition-api-faq.html)

View File

@@ -0,0 +1,104 @@
---
title: Boolean Prop Type Order Affects Casting Behavior
impact: MEDIUM
impactDescription: Wrong type order causes boolean props to be parsed as empty strings instead of true
type: gotcha
tags: [vue3, props, boolean, type-casting, validation]
---
# Boolean Prop Type Order Affects Casting Behavior
**Impact: MEDIUM** - When a prop accepts multiple types including both `Boolean` and `String`, the order in the type array determines how attribute-only syntax (e.g., `<Component disabled />`) is parsed. Incorrect ordering can result in `""` (empty string) instead of `true`.
Vue applies special boolean casting rules, but String appearing before Boolean disables this casting.
## Task Checklist
- [ ] Place `Boolean` before `String` in type arrays when you want boolean casting
- [ ] Test attribute-only syntax (`<Component disabled />`) to verify expected behavior
- [ ] Consider using only `Boolean` type if the prop is truly boolean
- [ ] Document the expected usage if both String and Boolean are intentional
**Incorrect:**
```vue
<script setup>
// WRONG: String before Boolean disables boolean casting
defineProps({
disabled: [String, Boolean] // disabled="" is parsed as empty string ""
})
</script>
<!-- In parent template -->
<MyComponent disabled /> <!-- props.disabled === "" (empty string, not true!) -->
```
```vue
<script setup>
defineProps({
// PROBLEMATIC: Order matters and may cause confusion
loading: [String, Boolean], // <Component loading /> gives ""
active: [Boolean, String] // <Component active /> gives true
})
</script>
```
**Correct:**
```vue
<script setup>
// CORRECT: Boolean before String enables boolean casting
defineProps({
disabled: [Boolean, String] // <Component disabled /> parsed as true
})
</script>
<!-- All of these work as expected -->
<MyComponent disabled /> <!-- props.disabled === true -->
<MyComponent :disabled="true" /> <!-- props.disabled === true -->
<MyComponent :disabled="false" /> <!-- props.disabled === false -->
<MyComponent disabled="custom" /> <!-- props.disabled === "custom" -->
```
```vue
<script setup>
// BEST: Use only Boolean if you don't need String values
defineProps({
disabled: Boolean // Clear intent, no ambiguity
})
</script>
```
## Boolean Casting Rules
| Prop Declaration | Template Usage | Resulting Value |
|-----------------|----------------|-----------------|
| `Boolean` | `<C disabled />` | `true` |
| `Boolean` | `<C />` (absent) | `false` |
| `[Boolean, String]` | `<C disabled />` | `true` |
| `[String, Boolean]` | `<C disabled />` | `""` (empty string) |
| `[Boolean, Number]` | `<C disabled />` | `true` |
| `[Number, Boolean]` | `<C disabled />` | `true` |
Note: The String type is special - it's the only type that overrides Boolean casting when placed first.
## When to Use Multiple Types
```vue
<script setup>
// Use case: Prop can be a boolean toggle OR a string configuration
defineProps({
// Animation can be true/false OR a timing string like "fast", "slow"
animate: {
type: [Boolean, String], // Boolean first for casting
default: false
}
})
</script>
<!-- Usage examples -->
<Dialog animate /> <!-- true - use default animation -->
<Dialog :animate="false" /> <!-- false - no animation -->
<Dialog animate="slow" /> <!-- "slow" - custom timing -->
```
## Reference
- [Vue.js Props - Boolean Casting](https://vuejs.org/guide/components/props.html#boolean-casting)

View File

@@ -0,0 +1,158 @@
---
title: Preserve Reactivity When Passing Props to Composables
impact: HIGH
impactDescription: Passing prop values directly to composables loses reactivity - composable won't update when props change
type: gotcha
tags: [vue3, props, composables, reactivity, composition-api]
---
# Preserve Reactivity When Passing Props to Composables
**Impact: HIGH** - A common mistake is passing data received from a prop directly to a composable. This passes the current value, not a reactive source. When the prop updates, the composable won't receive the new value, leading to stale data.
This is one of the most frequent sources of "my composable doesn't update" bugs in Vue 3.
## Task Checklist
- [ ] Pass props to composables via computed properties or getter functions
- [ ] Use `toRefs()` when passing multiple props to maintain reactivity
- [ ] In composables, use `toValue()` to normalize inputs that may be getters or refs
- [ ] Test that composable output updates when props change
**Incorrect:**
```vue
<script setup>
import { useFetch } from './composables/useFetch'
import { useDebounce } from './composables/useDebounce'
const props = defineProps({
userId: Number,
searchQuery: String
})
// WRONG: Passes initial value, not reactive source
// useFetch will never refetch when userId changes!
const { data } = useFetch(`/api/users/${props.userId}`)
// WRONG: Debounced value is frozen at initial searchQuery
const debouncedQuery = useDebounce(props.searchQuery, 300)
</script>
```
**Correct:**
```vue
<script setup>
import { computed } from 'vue'
import { useFetch } from './composables/useFetch'
import { useDebounce } from './composables/useDebounce'
const props = defineProps({
userId: Number,
searchQuery: String
})
// CORRECT: Use computed to create reactive URL
const userUrl = computed(() => `/api/users/${props.userId}`)
const { data } = useFetch(userUrl)
// CORRECT: Pass getter function to preserve reactivity
const debouncedQuery = useDebounce(() => props.searchQuery, 300)
</script>
```
## Pattern: Using toRefs for Multiple Props
```vue
<script setup>
import { toRefs } from 'vue'
import { useUserForm } from './composables/useUserForm'
const props = defineProps({
initialName: String,
initialEmail: String,
initialAge: Number
})
// Convert all props to refs, preserving reactivity
const { initialName, initialEmail, initialAge } = toRefs(props)
// Now each is a ref that tracks prop changes
const { form, isValid } = useUserForm({
name: initialName,
email: initialEmail,
age: initialAge
})
</script>
```
## Writing Reactivity-Safe Composables
Composables should accept multiple input types using `toValue()`:
```javascript
// composables/useDebounce.js
import { ref, watch, toValue } from 'vue'
export function useDebounce(source, delay = 300) {
// toValue() handles: ref, getter function, or plain value
const debounced = ref(toValue(source))
let timeout
watch(
() => toValue(source), // Normalizes any input type
(newValue) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debounced.value = newValue
}, delay)
},
{ immediate: true }
)
return debounced
}
```
```javascript
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
watchEffect(async () => {
loading.value = true
error.value = null
try {
// toValue() makes this work with computed, getter, or string
const response = await fetch(toValue(url))
data.value = await response.json()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
return { data, error, loading }
}
```
## Quick Reference: Input Types
| Input to Composable | Reactive? | Example |
|---------------------|-----------|---------|
| `props.value` | No | `useFetch(props.userId)` |
| `computed(() => ...)` | Yes | `useFetch(computed(() => props.userId))` |
| `() => props.value` | Yes* | `useFetch(() => props.userId)` |
| `toRef(props, 'key')` | Yes | `useFetch(toRef(props, 'userId'))` |
| `toRefs(props).key` | Yes | `const { userId } = toRefs(props); useFetch(userId)` |
*Requires composable to use `toValue()` internally
## Reference
- [Vue.js Reactivity API - toValue](https://vuejs.org/api/reactivity-utilities.html#tovalue)
- [Vue.js Composables - Conventions and Best Practices](https://vuejs.org/guide/reusability/composables.html#conventions-and-best-practices)

View File

@@ -0,0 +1,138 @@
---
title: Wrap Destructured Props in Getters for watch and Composables
impact: MEDIUM
impactDescription: Passing destructured prop values directly to watch or composables loses reactivity
type: gotcha
tags: [vue3, props, reactivity, watch, composables, destructuring]
---
# Wrap Destructured Props in Getters for watch and Composables
**Impact: MEDIUM** - When you destructure props with `defineProps`, the destructured variables are reactive in templates but passing them directly to `watch()` or external composables will pass the current value, not a reactive source. The watcher or composable won't track future changes.
Vue 3.5+ automatically transforms destructured props for template reactivity, but external functions still need getter wrappers.
## Task Checklist
- [ ] Wrap destructured props in arrow functions when passing to `watch()`
- [ ] Use getter functions when passing destructured props to composables
- [ ] Verify composables use `toValue()` to normalize getter/ref inputs
- [ ] Consider using `props.propertyName` directly if getter syntax feels awkward
**Incorrect:**
```vue
<script setup>
import { watch } from 'vue'
import { useDebounce } from './composables'
const { searchQuery, userId } = defineProps(['searchQuery', 'userId'])
// WRONG: Passing value, not reactive source
// This captures the initial value only - changes won't trigger the watcher
watch(searchQuery, (newValue) => {
console.log('Query changed:', newValue) // Never fires after initial!
})
// WRONG: Composable receives static value
const debouncedQuery = useDebounce(searchQuery, 300) // Won't update
</script>
```
**Correct:**
```vue
<script setup>
import { watch } from 'vue'
import { useDebounce } from './composables'
const { searchQuery, userId } = defineProps(['searchQuery', 'userId'])
// CORRECT: Wrap in getter function to maintain reactivity
watch(() => searchQuery, (newValue) => {
console.log('Query changed:', newValue) // Fires on every change
})
// CORRECT: Pass getter to composable
const debouncedQuery = useDebounce(() => searchQuery, 300)
</script>
```
## Alternative: Use props Object Directly
```vue
<script setup>
import { watch } from 'vue'
const props = defineProps(['searchQuery', 'userId'])
// Also correct: Watch via props object (no getter needed)
watch(() => props.searchQuery, (newValue) => {
console.log('Query changed:', newValue)
})
// Watch multiple props
watch(
() => [props.searchQuery, props.userId],
([newQuery, newUserId]) => {
console.log('Props changed:', newQuery, newUserId)
}
)
</script>
```
## Writing Composables That Accept Props
When creating composables that should work with destructured props:
```javascript
// composables/useDebounce.js
import { ref, watch, toValue } from 'vue'
export function useDebounce(source, delay = 300) {
const debounced = ref(toValue(source)) // toValue handles both getter and ref
let timeout
watch(
// toValue normalizes getter functions and refs
() => toValue(source),
(newValue) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debounced.value = newValue
}, delay)
}
)
return debounced
}
```
```vue
<!-- Usage -->
<script setup>
const { query } = defineProps(['query'])
// Works with getter
const debouncedQuery = useDebounce(() => query, 300)
</script>
```
## Vue 3.5+ Reactive Destructuring
Vue 3.5+ added reactive props destructuring. The compiler transforms:
```javascript
const { foo } = defineProps(['foo'])
```
Into something like:
```javascript
const __props = defineProps(['foo'])
// foo accesses __props.foo reactively in templates
```
However, external function calls still need getters because JavaScript itself passes values, not references.
## Reference
- [Vue.js Props - Reactive Props Destructure](https://vuejs.org/guide/components/props.html#reactive-props-destructure)
- [Vue.js Watchers - Watch Source Types](https://vuejs.org/guide/essentials/watchers.html#watch-source-types)

View File

@@ -0,0 +1,166 @@
---
title: Props Are Validated Before Component Instance Creation
impact: MEDIUM
impactDescription: Instance properties like data and computed are unavailable in prop default/validator functions
type: gotcha
tags: [vue3, props, validation, lifecycle, default-values]
---
# Props Are Validated Before Component Instance Creation
**Impact: MEDIUM** - Prop validation and default value functions execute before the component instance is created. This means `this`, `data`, `computed`, injections, and other instance properties are not available inside `default` or `validator` functions.
This timing catches developers who expect to use component state in prop validation logic.
## Task Checklist
- [ ] Never reference `this` or instance properties in prop default/validator functions
- [ ] Use factory functions for object/array defaults that only use the function parameters
- [ ] For validation depending on other props, use the second `props` parameter in validators
- [ ] Move complex validation logic to watchers or lifecycle hooks if instance access is needed
**Incorrect:**
```vue
<script>
export default {
data() {
return {
validOptions: ['a', 'b', 'c'],
defaultMessage: 'Hello'
}
},
props: {
option: {
type: String,
// WRONG: 'this' is undefined during prop validation
validator(value) {
return this.validOptions.includes(value) // TypeError!
}
},
message: {
type: String,
// WRONG: Cannot access data properties
default() {
return this.defaultMessage // TypeError!
}
},
config: {
type: Object,
// WRONG: Cannot access computed properties
default() {
return this.computedDefaults // TypeError!
}
}
}
}
</script>
```
**Correct:**
```vue
<script>
// Define validation data outside the component
const VALID_OPTIONS = ['a', 'b', 'c']
const DEFAULT_MESSAGE = 'Hello'
export default {
props: {
option: {
type: String,
// CORRECT: Use external constants
validator(value) {
return VALID_OPTIONS.includes(value)
}
},
message: {
type: String,
// CORRECT: Use external constant or inline value
default: DEFAULT_MESSAGE
},
config: {
type: Object,
// CORRECT: Factory function with no instance dependencies
default() {
return { theme: 'light', size: 'medium' }
}
}
}
}
</script>
```
## Using the `props` Parameter in Validators
Validators receive the full props object as the second parameter for cross-prop validation:
```vue
<script setup>
const props = defineProps({
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
value: {
type: Number,
required: true,
// CORRECT: Access other props via second parameter
validator(value, props) {
return value >= props.min && value <= props.max
}
}
})
</script>
```
## Using rawProps in Default Functions
Default factory functions receive `rawProps` for accessing other prop values:
```vue
<script setup>
defineProps({
size: {
type: String,
default: 'medium'
},
padding: {
type: Number,
// Access other prop values via rawProps parameter
default(rawProps) {
// Use size to determine default padding
return rawProps.size === 'large' ? 20 : 10
}
}
})
</script>
```
## Post-Mount Validation Pattern
For validation that needs instance access, use lifecycle hooks:
```vue
<script setup>
import { onMounted, inject, warn } from 'vue'
const props = defineProps({
theme: String
})
const availableThemes = inject('availableThemes', [])
// Validation that needs injected values
onMounted(() => {
if (props.theme && !availableThemes.includes(props.theme)) {
console.warn(`Invalid theme "${props.theme}". Available: ${availableThemes.join(', ')}`)
}
})
</script>
```
## Reference
- [Vue.js Props - Prop Validation](https://vuejs.org/guide/components/props.html#prop-validation)

View File

@@ -0,0 +1,214 @@
---
title: Props Are Read-Only - Never Mutate Props
impact: HIGH
impactDescription: Mutating props breaks one-way data flow and causes unpredictable parent-child state synchronization issues
type: gotcha
tags: [vue3, props, one-way-data-flow, mutation, component-design]
---
# Props Are Read-Only - Never Mutate Props
**Impact: HIGH** - Props in Vue follow one-way data flow: parent to child only. Mutating a prop in a child component violates this contract, triggering Vue warnings and causing hard-to-debug state synchronization issues. The parent component loses control of the data it passed down.
Props are "snapshots" from the parent at render time. Vue's reactivity system tracks props at the parent level - mutating in the child doesn't notify the parent, leading to state inconsistencies.
This is especially dangerous with object/array props because JavaScript passes them by reference, allowing mutation without assignment.
## Task Checklist
- [ ] Never assign new values to props
- [ ] Never mutate object or array prop properties directly
- [ ] Use emit to request parent to make changes
- [ ] Create local copies if you need to modify prop-based data
- [ ] Use computed properties for derived values
## The Problem
**Incorrect - Direct primitive prop mutation:**
```vue
<script setup>
const props = defineProps({
count: Number
})
// WRONG: Vue will warn about mutating props
function increment() {
props.count++ // Mutation attempt - this WILL fail
}
</script>
```
**Incorrect - Object/array prop mutation (silent but dangerous):**
```vue
<script setup>
const props = defineProps({
user: Object,
items: Array
})
// WRONG: No warning, but breaks data flow!
function updateUser() {
props.user.name = 'New Name' // Mutates parent's object
}
function addItem() {
props.items.push({ id: 1 }) // Mutates parent's array
}
</script>
```
This pattern is dangerous because:
1. Parent component doesn't know about the change
2. Data can become out of sync
3. Makes debugging difficult - where did the change come from?
4. Breaks the component contract
## Pattern 1: Emit Events to Parent
Let the parent handle all data changes.
**Correct:**
```vue
<!-- ChildComponent.vue -->
<script setup>
const props = defineProps({
count: Number,
user: Object
})
const emit = defineEmits(['update:count', 'update-user'])
function increment() {
emit('update:count', props.count + 1)
}
function updateName(newName) {
emit('update-user', { ...props.user, name: newName })
}
</script>
```
```vue
<!-- ParentComponent.vue -->
<template>
<ChildComponent
:count="count"
:user="user"
@update:count="count = $event"
@update-user="user = $event"
/>
</template>
```
## Pattern 2: Local Copy for Editable Data (Prop as Initial Value)
When the component needs to work with a modified version of prop data.
**Correct:**
```vue
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
initialValue: String,
user: Object
})
// Local copy for editing
const localValue = ref(props.initialValue)
// Deep copy for objects
const localUser = ref({ ...props.user })
// Sync when parent changes the prop
watch(() => props.initialValue, (newVal) => {
localValue.value = newVal
})
watch(() => props.user, (newUser) => {
localUser.value = { ...newUser }
}, { deep: true })
</script>
<template>
<input v-model="localValue" />
<input v-model="localUser.name" />
</template>
```
## Pattern 3: Computed Properties for Transformations
When you need a derived/transformed version of the prop.
**Correct:**
```vue
<script setup>
import { computed } from 'vue'
const props = defineProps({
text: String,
items: Array
})
// Derived value - doesn't mutate prop
const uppercaseText = computed(() => props.text.toUpperCase())
// Filtered view - doesn't mutate prop
const activeItems = computed(() =>
props.items.filter(item => item.active)
)
</script>
```
## Pattern 4: v-model for Two-Way Binding
For form-like components that need two-way binding.
**Correct:**
```vue
<!-- CustomInput.vue -->
<script setup>
const props = defineProps({
modelValue: String
})
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
```
```vue
<!-- ParentComponent.vue -->
<template>
<!-- v-model is shorthand for :modelValue + @update:modelValue -->
<CustomInput v-model="text" />
</template>
```
## Common Mistake: Thinking Object Mutation Is Safe
```vue
<script setup>
const props = defineProps({ config: Object })
// This "works" but is an anti-pattern!
props.config.theme = 'dark' // No Vue warning, but still wrong
</script>
```
Vue doesn't warn because it can't efficiently detect deep mutations. But this still:
- Breaks one-way data flow
- Makes the component unpredictable
- Causes maintenance nightmares
**Always treat props as if they were deeply frozen.**
## Reference
- [Vue.js Props - One-Way Data Flow](https://vuejs.org/guide/components/props.html#one-way-data-flow)
- [Vue.js Props - Mutating Object/Array Props](https://vuejs.org/guide/components/props.html#mutating-object-array-props)

View File

@@ -0,0 +1,190 @@
---
title: Keep Provide/Inject Mutations in the Provider Component
impact: MEDIUM
impactDescription: Allowing injectors to mutate provided state leads to unpredictable data flow and difficult debugging
type: best-practice
tags: [vue3, provide-inject, state-management, architecture, debugging]
---
# Keep Provide/Inject Mutations in the Provider Component
**Impact: MEDIUM** - When using reactive provide/inject values, mutations should be kept inside the provider component whenever possible. Allowing child components to mutate injected state directly leads to confusion about where changes originate, making debugging and maintenance difficult.
## Task Checklist
- [ ] Keep all mutations to provided state in the provider component
- [ ] Provide update functions alongside reactive data for child modifications
- [ ] Use `readonly()` to prevent accidental mutations from injectors
- [ ] Document provided values and their update patterns
## The Problem: Scattered Mutations
**Wrong - Injector mutates provided state directly:**
```vue
<!-- Provider.vue -->
<script setup>
import { ref, provide } from 'vue'
const user = ref({ name: 'John', preferences: { theme: 'dark' } })
provide('user', user)
</script>
```
```vue
<!-- DeepChild.vue -->
<script setup>
import { inject } from 'vue'
const user = inject('user')
// PROBLEMATIC: Mutating from anywhere in the tree
function updateTheme(theme) {
user.value.preferences.theme = theme // Where did this change come from?
}
</script>
```
**Problems:**
1. Hard to trace where state changes originate
2. Multiple components might mutate the same data inconsistently
3. No centralized validation or side effects
4. Debugging becomes a nightmare in large apps
## Solution: Provide Update Functions
**Correct - Provider controls all mutations:**
```vue
<!-- Provider.vue -->
<script setup>
import { ref, provide, readonly } from 'vue'
const user = ref({ name: 'John', preferences: { theme: 'dark' } })
// Mutation function with validation
function updateUserPreferences(preferences) {
// Centralized validation
if (preferences.theme && !['dark', 'light', 'system'].includes(preferences.theme)) {
console.warn('Invalid theme')
return false
}
// Centralized side effects
Object.assign(user.value.preferences, preferences)
localStorage.setItem('userPrefs', JSON.stringify(user.value.preferences))
return true
}
function updateUserName(name) {
if (!name || name.length < 2) {
console.warn('Name must be at least 2 characters')
return false
}
user.value.name = name
return true
}
// Provide readonly data + update functions
provide('user', {
data: readonly(user),
updatePreferences: updateUserPreferences,
updateName: updateUserName
})
</script>
```
```vue
<!-- DeepChild.vue -->
<script setup>
import { inject } from 'vue'
const { data: user, updatePreferences } = inject('user')
function changeTheme(theme) {
// Clear intent: calling provider's update function
const success = updatePreferences({ theme })
if (!success) {
// Handle validation failure
}
}
</script>
<template>
<!-- data is readonly, prevents accidental mutation -->
<div>Theme: {{ user.preferences.theme }}</div>
<button @click="changeTheme('light')">Light Mode</button>
</template>
```
## Pattern: Provide/Inject with Readonly Protection
Use `readonly()` to enforce the pattern at runtime:
```vue
<!-- Provider.vue -->
<script setup>
import { ref, provide, readonly } from 'vue'
const cart = ref([])
function addItem(item) {
cart.value.push(item)
}
function removeItem(id) {
cart.value = cart.value.filter(item => item.id !== id)
}
function clearCart() {
cart.value = []
}
// Provide readonly cart + controlled mutations
provide('cart', {
items: readonly(cart),
addItem,
removeItem,
clearCart
})
</script>
```
```vue
<!-- CartDisplay.vue -->
<script setup>
import { inject } from 'vue'
const { items, removeItem } = inject('cart')
// items.push(newItem) would trigger a warning in dev mode
// Must use provided removeItem function
</script>
<template>
<div v-for="item in items" :key="item.id">
{{ item.name }}
<button @click="removeItem(item.id)">Remove</button>
</div>
</template>
```
## Benefits of This Pattern
1. **Traceability**: All mutations go through known functions
2. **Validation**: Centralized validation in provider
3. **Side Effects**: Consistent side effects (logging, storage, API calls)
4. **Testing**: Easier to test mutation logic in isolation
5. **Debugging**: Clear mutation source in Vue DevTools
## When Direct Mutation Might Be Acceptable
In rare cases, direct mutation may be acceptable:
- Very simple, local state within a small component tree
- Form state that's isolated to a single form wizard
- Temporary state that doesn't affect app logic
Even then, consider using `readonly()` with update functions for consistency.
## Reference
- [Vue.js Provide/Inject - Working with Reactivity](https://vuejs.org/guide/components/provide-inject.html#working-with-reactivity)
- [The Complete Guide to Provide/Inject API in Vue 3](https://www.codemag.com/Article/2101091/The-Complete-Guide-to-Provide-Inject-API-in-Vue-3-Part-1)

View File

@@ -0,0 +1,232 @@
---
title: Use Symbol Keys for Provide/Inject in Large Applications
impact: MEDIUM
impactDescription: String injection keys can collide in large applications or when using third-party components
type: best-practice
tags: [vue3, provide-inject, typescript, architecture, component-library]
---
# Use Symbol Keys for Provide/Inject in Large Applications
**Impact: MEDIUM** - Using string keys for provide/inject works for small applications but can cause key collisions in large apps, component libraries, or when multiple teams work on the same codebase. Symbol keys guarantee uniqueness and provide better TypeScript integration.
## Task Checklist
- [ ] Use Symbol keys for provide/inject in large applications
- [ ] Export symbols from a dedicated keys file
- [ ] Use `InjectionKey<T>` for TypeScript type safety
- [ ] Reserve string keys for simple, local use cases only
## The Problem: String Key Collisions
**Risky - String keys can collide:**
```vue
<!-- ThemeProvider.vue (your code) -->
<script setup>
import { provide, ref } from 'vue'
provide('theme', ref('dark'))
</script>
<!-- SomeThirdPartyComponent.vue (library) -->
<script setup>
import { provide, ref } from 'vue'
// Oops! Same key, different value
provide('theme', ref({ primary: 'blue', secondary: 'gray' }))
</script>
<!-- DeepChild.vue -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme') // Which one? Closest ancestor wins
</script>
```
## Solution: Symbol Keys
**Correct - Unique symbols prevent collisions:**
```js
// injection-keys.js
export const ThemeKey = Symbol('theme')
export const UserKey = Symbol('user')
export const ConfigKey = Symbol('config')
```
```vue
<!-- ThemeProvider.vue -->
<script setup>
import { provide, ref } from 'vue'
import { ThemeKey } from '@/injection-keys'
const theme = ref('dark')
provide(ThemeKey, theme)
</script>
```
```vue
<!-- ThemeConsumer.vue -->
<script setup>
import { inject } from 'vue'
import { ThemeKey } from '@/injection-keys'
const theme = inject(ThemeKey)
</script>
```
## TypeScript: InjectionKey for Type Safety
Vue provides `InjectionKey<T>` for strongly-typed injection:
```ts
// injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
// Define the injected type
interface User {
id: string
name: string
email: string
}
interface ThemeConfig {
mode: 'light' | 'dark'
primaryColor: string
}
// Create typed injection keys
export const UserKey: InjectionKey<Ref<User>> = Symbol('user')
export const ThemeKey: InjectionKey<Ref<ThemeConfig>> = Symbol('theme')
export const LoggerKey: InjectionKey<(msg: string) => void> = Symbol('logger')
```
```vue
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { UserKey, ThemeKey } from '@/injection-keys'
const user = ref({
id: '123',
name: 'John',
email: 'john@example.com'
})
const theme = ref({
mode: 'dark' as const,
primaryColor: '#007bff'
})
// TypeScript validates the provided value matches the key's type
provide(UserKey, user)
provide(ThemeKey, theme)
</script>
```
```vue
<!-- Consumer.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { UserKey, ThemeKey } from '@/injection-keys'
// TypeScript knows user is Ref<User> | undefined
const user = inject(UserKey)
// With default value, TypeScript knows it's not undefined
const theme = inject(ThemeKey, ref({ mode: 'light', primaryColor: '#000' }))
// Type-safe access
console.log(user?.value.name) // TypeScript knows the shape
console.log(theme.value.mode) // 'light' | 'dark'
</script>
```
## Pattern: Injection Keys File Organization
For larger applications, organize keys by feature:
```
src/
injection-keys/
index.ts # Re-exports all keys
auth.ts # Auth-related keys
theme.ts # Theme-related keys
feature-x.ts # Feature-specific keys
```
```ts
// injection-keys/auth.ts
import type { InjectionKey, Ref, ComputedRef } from 'vue'
export interface AuthState {
user: User | null
isAuthenticated: boolean
permissions: string[]
}
export interface AuthActions {
login: (credentials: Credentials) => Promise<void>
logout: () => Promise<void>
checkPermission: (permission: string) => boolean
}
export const AuthStateKey: InjectionKey<Ref<AuthState>> = Symbol('auth-state')
export const AuthActionsKey: InjectionKey<AuthActions> = Symbol('auth-actions')
```
```ts
// injection-keys/index.ts
export * from './auth'
export * from './theme'
export * from './feature-x'
```
## Handling Missing Injections with Types
TypeScript helps catch missing providers:
```vue
<script setup lang="ts">
import { inject } from 'vue'
import { UserKey } from '@/injection-keys'
// Option 1: Handle undefined explicitly
const user = inject(UserKey)
if (!user) {
throw new Error('UserKey must be provided by an ancestor component')
}
// Option 2: Provide a default
const userWithDefault = inject(UserKey, ref({
id: 'guest',
name: 'Guest User',
email: ''
}))
// Option 3: Use non-null assertion (only if you're certain)
const userRequired = inject(UserKey)!
</script>
```
## When String Keys Are Still OK
String keys are acceptable for:
- Small applications with few providers
- Local component trees with clear boundaries
- Quick prototypes
- App-level provides with unique, namespaced strings
```vue
<!-- App-level provides with namespaced strings -->
<script setup>
import { provide } from 'vue'
// Namespaced strings reduce collision risk
provide('myapp:config', config)
provide('myapp:analytics', analytics)
</script>
```
## Reference
- [Vue.js Provide/Inject - Working with Symbol Keys](https://vuejs.org/guide/components/provide-inject.html#working-with-symbol-keys)
- [Vue.js TypeScript - Typing Provide/Inject](https://vuejs.org/guide/typescript/composition-api.html#typing-provide-inject)

View File

@@ -0,0 +1,89 @@
---
title: Never Destructure reactive() Objects Directly
impact: HIGH
impactDescription: Destructuring reactive objects breaks reactivity - changes won't trigger updates
type: capability
tags: [vue3, reactivity, reactive, composition-api, destructuring]
---
# Never Destructure reactive() Objects Directly
**Impact: HIGH** - Destructuring a `reactive()` object breaks the reactive connection. Updates to destructured variables won't trigger UI updates, leading to stale data display.
Vue's `reactive()` uses JavaScript Proxies to track property access. When you destructure, you extract primitive values from the proxy, losing the reactive connection. This is especially dangerous when destructuring from composables or imported state.
## Task Checklist
- [ ] Never destructure reactive objects directly if you need reactivity
- [ ] Use `toRefs()` to convert reactive object properties to refs before destructuring
- [ ] Consider using `ref()` instead of `reactive()` to avoid this pitfall entirely
- [ ] When importing state from composables, check if it's reactive before destructuring
**Incorrect:**
```javascript
import { reactive } from 'vue'
const state = reactive({
count: 0,
name: 'Vue'
})
// WRONG: Destructuring breaks reactivity
const { count, name } = state
// These updates work on the original state...
state.count++ // state.count is now 1
// ...but the destructured variables are NOT updated
console.log(count) // Still 0! Lost reactivity
```
```javascript
// WRONG: Destructuring from a composable
function useCounter() {
const state = reactive({ count: 0 })
return state
}
const { count } = useCounter() // count is now a non-reactive primitive
```
**Correct:**
```javascript
import { reactive, toRefs } from 'vue'
const state = reactive({
count: 0,
name: 'Vue'
})
// CORRECT: Use toRefs() to maintain reactivity
const { count, name } = toRefs(state)
state.count++
console.log(count.value) // 1 - Reactivity preserved! (note: now needs .value)
```
```javascript
// CORRECT: Return toRefs from composables
function useCounter() {
const state = reactive({ count: 0 })
return toRefs(state) // Now safe to destructure
}
const { count } = useCounter() // count is now a ref, reactivity preserved
```
```javascript
// ALTERNATIVE: Just use ref() to avoid the issue entirely
import { ref } from 'vue'
const count = ref(0)
const name = ref('Vue')
// No destructuring needed, no gotchas
```
## Reference
- [Vue.js Reactivity Fundamentals - reactive()](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#reactive)
- [Vue.js Reactivity API - toRefs()](https://vuejs.org/api/reactivity-utilities.html#torefs)

View File

@@ -0,0 +1,128 @@
---
title: Use computed() Instead of watchEffect() for Derived State
impact: MEDIUM
impactDescription: Using watchEffect to mutate refs creates unnecessary indirection - computed() is declarative and cached
type: efficiency
tags: [vue3, reactivity, computed, watchEffect, best-practice, performance]
---
# Use computed() Instead of watchEffect() for Derived State
**Impact: MEDIUM** - When you need state that derives from other reactive state, always prefer `computed()` over using `watchEffect()` to mutate a ref. Computed properties are declarative, automatically cached, and clearly express the dependency relationship.
Using `watchEffect()` to mutate a ref works but creates unnecessary indirection: you're imperatively updating state based on dependencies rather than declaring the relationship. This makes the code harder to understand and prevents Vue from optimizing.
## Task Checklist
- [ ] Use `computed()` when the result is a pure transformation of reactive state
- [ ] Use `watchEffect()` only for side effects (DOM manipulation, logging, API calls)
- [ ] Never use watchEffect to mutate a ref just to derive a value
- [ ] Remember computed values are cached and only re-compute when dependencies change
**Incorrect:**
```javascript
import { ref, watchEffect } from 'vue'
const A0 = ref(1)
const A1 = ref(2)
const A2 = ref() // Unnecessary ref
// WRONG: Using watchEffect to derive state
watchEffect(() => {
A2.value = A0.value + A1.value
})
// Problems:
// 1. A2 is writable when it shouldn't be
// 2. Imperative instead of declarative
// 3. No caching optimization
// 4. Harder to trace dependencies
```
```javascript
// WRONG: Complex derived state with watchEffect
const items = ref([{ price: 10 }, { price: 20 }])
const total = ref(0)
watchEffect(() => {
total.value = items.value.reduce((sum, item) => sum + item.price, 0)
})
```
**Correct:**
```javascript
import { ref, computed } from 'vue'
const A0 = ref(1)
const A1 = ref(2)
// CORRECT: Declarative derived state
const A2 = computed(() => A0.value + A1.value)
// Benefits:
// 1. A2 is read-only by default
// 2. Clearly declares the dependency relationship
// 3. Cached - only recalculates when A0 or A1 changes
// 4. Easy to understand data flow
```
```javascript
// CORRECT: Complex derived state with computed
const items = ref([{ price: 10 }, { price: 20 }])
const total = computed(() => {
return items.value.reduce((sum, item) => sum + item.price, 0)
})
// Multiple derived values
const itemCount = computed(() => items.value.length)
const averagePrice = computed(() =>
items.value.length ? total.value / itemCount.value : 0
)
```
**When watchEffect IS appropriate:**
```javascript
import { ref, watchEffect } from 'vue'
const searchQuery = ref('')
// CORRECT: watchEffect for side effects
watchEffect(() => {
// Logging
console.log(`Search query changed: ${searchQuery.value}`)
// DOM manipulation
document.title = `Search: ${searchQuery.value}`
})
// CORRECT: watchEffect for async side effects
watchEffect(async () => {
if (searchQuery.value) {
// API call (side effect, not derived state)
await api.logSearch(searchQuery.value)
}
})
```
**Summary of when to use each:**
```javascript
// Use computed() when:
// - You're deriving a value from reactive state
// - The result is pure (no side effects)
// - You want caching
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
// Use watchEffect() when:
// - You need to perform side effects
// - You're interacting with external systems
// - You need to run async operations
watchEffect(() => {
document.title = fullName.value // Side effect
})
```
## Reference
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
- [Vue.js Computed Properties](https://vuejs.org/guide/essentials/computed.html)
- [Vue.js Watchers](https://vuejs.org/guide/essentials/watchers.html)

View File

@@ -0,0 +1,165 @@
---
title: Use shallowRef Pattern for External State Libraries
impact: MEDIUM
impactDescription: External state systems (Immer, XState, Redux) should use shallowRef to avoid double-wrapping in proxies
type: efficiency
tags: [vue3, reactivity, shallowRef, external-state, immer, xstate, integration]
---
# Use shallowRef Pattern for External State Libraries
**Impact: MEDIUM** - When integrating Vue with external state management libraries (Immer, XState, Redux, MobX), use `shallowRef()` to hold the external state. This prevents Vue from deep-wrapping the external state in proxies, which can cause conflicts and performance issues.
The pattern: hold external state in a `shallowRef`, and replace `.value` entirely when the external system updates. This gives Vue reactivity while letting the external library manage state internals.
## Task Checklist
- [ ] Use `shallowRef()` to hold external library state
- [ ] Replace `.value` entirely when external state changes (don't mutate)
- [ ] Integrate update functions that produce new state objects
- [ ] Consider this pattern for immutable data structures
**Integrating with Immer:**
```javascript
import { produce } from 'immer'
import { shallowRef } from 'vue'
export function useImmer(baseState) {
const state = shallowRef(baseState)
function update(updater) {
// Immer produces a new immutable state
// Replace shallowRef value entirely to trigger reactivity
state.value = produce(state.value, updater)
}
return [state, update]
}
// Usage
const [todos, updateTodos] = useImmer([
{ id: 1, text: 'Learn Vue', done: false }
])
// Update with Immer's mutable API (produces immutable result)
updateTodos(draft => {
draft[0].done = true
draft.push({ id: 2, text: 'Use Immer', done: false })
})
```
**Integrating with XState:**
```javascript
import { createMachine, interpret } from 'xstate'
import { shallowRef, onUnmounted } from 'vue'
export function useMachine(options) {
const machine = createMachine(options)
const state = shallowRef(machine.initialState)
const service = interpret(machine)
.onTransition((newState) => {
// Replace state entirely on each transition
state.value = newState
})
.start()
const send = (event) => service.send(event)
onUnmounted(() => service.stop())
return { state, send }
}
// Usage
const { state, send } = useMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: { on: { TOGGLE: 'active' } },
active: { on: { TOGGLE: 'inactive' } }
}
})
// In template: state.value.matches('active')
send('TOGGLE')
```
**Integrating with Redux-style stores:**
```javascript
import { shallowRef, readonly } from 'vue'
export function createStore(reducer, initialState) {
const state = shallowRef(initialState)
function dispatch(action) {
state.value = reducer(state.value, action)
}
function getState() {
return state.value
}
return {
state: readonly(state), // Prevent direct mutations
dispatch,
getState
}
}
// Usage
const store = createStore(
(state, action) => {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 }
default:
return state
}
},
{ count: 0 }
)
store.dispatch({ type: 'INCREMENT' })
console.log(store.state.value.count) // 1
```
**Why NOT use ref() for external state:**
```javascript
import { ref } from 'vue'
import { produce } from 'immer'
// WRONG: ref() deep-wraps the state
const state = ref({ nested: { value: 1 } })
// This creates double-proxying:
// 1. Vue wraps state in Proxy
// 2. External library may also wrap/expect raw objects
// 3. Causes identity issues and potential conflicts
// WRONG: Mutating ref with Immer
state.value = produce(state.value, draft => {
draft.nested.value = 2
})
// Vue's deep proxy on state.value may interfere with Immer's proxies
```
**Correct pattern with shallowRef:**
```javascript
import { shallowRef } from 'vue'
// CORRECT: shallowRef only tracks .value replacement
const state = shallowRef({ nested: { value: 1 } })
// External library works with raw objects inside
state.value = produce(state.value, draft => {
draft.nested.value = 2
})
// Clean separation: Vue tracks outer ref, library manages inner state
```
## Reference
- [Vue.js Reactivity in Depth - Integration with External State](https://vuejs.org/guide/extras/reactivity-in-depth.html#integration-with-external-state-systems)
- [Vue.js shallowRef API](https://vuejs.org/api/reactivity-advanced.html#shallowref)
- [Immer Documentation](https://immerjs.github.io/immer/)
- [XState Documentation](https://xstate.js.org/)

View File

@@ -0,0 +1,149 @@
---
title: Use markRaw() for Objects That Should Never Be Reactive
impact: MEDIUM
impactDescription: Library instances, DOM nodes, and complex objects cause overhead and bugs when wrapped in Vue proxies
type: efficiency
tags: [vue3, reactivity, markRaw, performance, external-libraries, dom]
---
# Use markRaw() for Objects That Should Never Be Reactive
**Impact: MEDIUM** - Vue's `markRaw()` tells the reactivity system to never wrap an object in a Proxy. Use it for library instances, DOM nodes, class instances with internal state, and complex objects that Vue shouldn't track. This prevents unnecessary proxy overhead and avoids subtle bugs from double-proxying.
Without `markRaw()`, placing these objects inside reactive state causes Vue to wrap them in Proxies, which can break library internals, cause identity issues, and waste memory on objects that don't need change tracking.
## Task Checklist
- [ ] Use `markRaw()` for third-party library instances (maps, charts, editors)
- [ ] Use `markRaw()` for DOM elements stored in reactive state
- [ ] Use `markRaw()` for class instances that manage their own state
- [ ] Use `markRaw()` for large static data that will never change
- [ ] Remember: markRaw only affects the root level - nested objects may still be proxied
**Incorrect:**
```javascript
import { reactive, ref } from 'vue'
import mapboxgl from 'mapbox-gl'
import * as monaco from 'monaco-editor'
// WRONG: Library instances wrapped in Proxy
const state = reactive({
map: new mapboxgl.Map({ container: 'map' }), // Proxied!
editor: monaco.editor.create(element, {}), // Proxied!
})
// Problems:
// 1. Library's internal this references may break
// 2. Unnecessary memory overhead
// 3. Methods may not work correctly through proxy
// 4. Performance degradation
// WRONG: DOM elements in reactive state
const elements = reactive({
container: document.getElementById('app'), // Proxied DOM node!
})
```
**Correct:**
```javascript
import { reactive, markRaw, shallowRef } from 'vue'
import mapboxgl from 'mapbox-gl'
import * as monaco from 'monaco-editor'
// CORRECT: Mark library instances as raw
const state = reactive({
map: markRaw(new mapboxgl.Map({ container: 'map' })),
editor: markRaw(monaco.editor.create(element, {})),
})
// CORRECT: Or use shallowRef for mutable references
const map = shallowRef(null)
onMounted(() => {
map.value = markRaw(new mapboxgl.Map({ container: 'map' }))
})
// CORRECT: Large static data
const geoJsonData = markRaw(await fetch('/huge-geojson.json').then(r => r.json()))
const state = reactive({
mapData: geoJsonData // Won't be proxied
})
```
**Class instances with internal state:**
```javascript
import { markRaw, reactive } from 'vue'
class WebSocketManager {
constructor(url) {
this.socket = new WebSocket(url)
this.listeners = new Map()
}
on(event, callback) {
this.listeners.set(event, callback)
}
}
// CORRECT: Mark class instance
const wsManager = markRaw(new WebSocketManager('ws://example.com'))
const state = reactive({
connection: wsManager // Won't be proxied
})
// Can still use the instance normally
state.connection.on('message', handleMessage)
```
**Gotcha: markRaw only affects root level:**
```javascript
import { markRaw, reactive } from 'vue'
const rawObject = markRaw({
nested: { value: 1 } // This nested object is NOT marked raw
})
const state = reactive({
data: rawObject
})
// rawObject itself won't be proxied
// But if you access nested objects through a reactive parent:
const container = reactive({ raw: rawObject })
// container.raw.nested might still be proxied in some cases
// SAFER: Use shallowRef for the container
import { shallowRef } from 'vue'
const safeContainer = shallowRef(rawObject)
```
**Combining with shallowRef for best results:**
```javascript
import { shallowRef, markRaw, onMounted, onUnmounted } from 'vue'
// Pattern: shallowRef + markRaw for external library instances
export function useMapbox(containerId) {
const map = shallowRef(null)
onMounted(() => {
const instance = new mapboxgl.Map({
container: containerId,
style: 'mapbox://styles/mapbox/streets-v11'
})
// Mark raw to prevent any proxy wrapping
map.value = markRaw(instance)
})
onUnmounted(() => {
map.value?.remove()
})
return { map }
}
```
## Reference
- [Vue.js markRaw() API](https://vuejs.org/api/reactivity-advanced.html#markraw)
- [Vue.js Reducing Reactivity Overhead](https://vuejs.org/guide/best-practices/performance.html#reduce-reactivity-overhead-for-large-immutable-structures)
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)

View File

@@ -0,0 +1,96 @@
---
title: Avoid Comparing Reactive Objects with === Operator
impact: HIGH
impactDescription: Reactive proxies have different identity than original objects - comparison bugs are silent and hard to debug
type: gotcha
tags: [vue3, reactivity, proxy, comparison, debugging, identity]
---
# Avoid Comparing Reactive Objects with === Operator
**Impact: HIGH** - Vue's `reactive()` returns a Proxy wrapper that has a different identity than the original object. Using `===` to compare reactive objects can lead to silent bugs where comparisons unexpectedly return `false`.
When you wrap an object with `reactive()`, the returned proxy is NOT equal to the original object. Additionally, accessing nested objects from a reactive object returns new proxy wrappers each time, which can cause identity comparison issues.
## Task Checklist
- [ ] Never compare reactive object instances with `===` directly
- [ ] Use unique identifiers (ID, UUID) for object comparison instead
- [ ] Use `toRaw()` on both sides when identity comparison is absolutely necessary
- [ ] Consider using primitive identifiers from database records for comparison
**Incorrect:**
```javascript
import { reactive } from 'vue'
const original = { id: 1, name: 'Item' }
const state = reactive(original)
// BUG: Always returns false - proxy !== original
if (state === original) {
console.log('Same object') // Never executes
}
// BUG: Nested object comparison fails
const items = reactive([{ id: 1 }, { id: 2 }])
const item = items[0]
// Later...
if (items[0] === item) {
// May or may not work depending on Vue's proxy caching
}
// BUG: Comparing items from different reactive sources
const listA = reactive([{ id: 1 }])
const listB = reactive([{ id: 1 }])
if (listA[0] === listB[0]) {
// Never true, even though they represent the same data
}
```
**Correct:**
```javascript
import { reactive, toRaw } from 'vue'
const original = { id: 1, name: 'Item' }
const state = reactive(original)
// CORRECT: Use toRaw() for identity comparison
if (toRaw(state) === original) {
console.log('Same underlying object') // Works!
}
// BEST: Use unique identifiers instead
const items = reactive([
{ id: 'uuid-1', name: 'Item 1' },
{ id: 'uuid-2', name: 'Item 2' }
])
function findItem(targetId) {
return items.find(item => item.id === targetId)
}
function isSelected(item) {
return selectedId.value === item.id // Compare IDs, not objects
}
// CORRECT: For Set/Map operations, use primitive keys
const selectedIds = reactive(new Set())
selectedIds.add(item.id) // Use ID, not object
selectedIds.has(item.id) // Check by ID
```
```javascript
// When you must compare objects, use toRaw on both sides
import { toRaw, isReactive } from 'vue'
function areEqual(a, b) {
const rawA = isReactive(a) ? toRaw(a) : a
const rawB = isReactive(b) ? toRaw(b) : b
return rawA === rawB
}
```
## Reference
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
- [Vue.js toRaw() API](https://vuejs.org/api/reactivity-advanced.html#toraw)

View File

@@ -0,0 +1,166 @@
---
title: Understand Reactive Updates are Batched Per Event Loop Tick
impact: MEDIUM
impactDescription: Multiple synchronous reactive changes are batched - watchers only see the final value, not intermediate states
type: gotcha
tags: [vue3, reactivity, batching, event-loop, watchers, nextTick]
---
# Understand Reactive Updates are Batched Per Event Loop Tick
**Impact: MEDIUM** - Vue batches multiple reactive state changes that happen synchronously within the same event loop tick. Watchers and computed properties only see the final state, not intermediate values. This is an optimization, but it can be surprising if you expect watchers to fire for each individual change.
Understanding this behavior is essential for debugging scenarios where you expect to observe every state transition.
## Task Checklist
- [ ] Understand watchers fire once per tick with final value, not for each mutation
- [ ] Use `nextTick()` if you need to ensure DOM updates between state changes
- [ ] Use `flush: 'sync'` on watchers only if you absolutely need immediate execution
- [ ] For intermediate value tracking, consider logging or explicit state snapshots
**Example of batching behavior:**
```javascript
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newValue) => {
console.log('Count changed to:', newValue)
})
// Multiple synchronous changes in the same tick
function multipleUpdates() {
count.value = 1
count.value = 2
count.value = 3
count.value = 4
}
multipleUpdates()
// Console output: "Count changed to: 4"
// NOT: 1, 2, 3, 4 - only the final value is observed!
```
**The console logs you WON'T see:**
```javascript
const items = reactive([])
watch(items, (newItems) => {
console.log('Items count:', newItems.length)
})
// Batch of changes
items.push('a') // length: 1
items.push('b') // length: 2
items.push('c') // length: 3
// Output: "Items count: 3"
// You won't see 1, 2, 3 logged separately
```
**Using flush: 'sync' for immediate watching (use with caution):**
```javascript
import { ref, watch } from 'vue'
const count = ref(0)
// Sync watcher fires immediately on each change
watch(count, (newValue) => {
console.log('Immediate:', newValue)
}, { flush: 'sync' })
count.value = 1 // Logs: "Immediate: 1"
count.value = 2 // Logs: "Immediate: 2"
count.value = 3 // Logs: "Immediate: 3"
// WARNING: flush: 'sync' can cause performance issues
// and creates less predictable behavior. Avoid if possible.
```
**Using nextTick to separate batches:**
```javascript
import { ref, watch, nextTick } from 'vue'
const count = ref(0)
watch(count, (newValue) => {
console.log('Count:', newValue)
})
async function separatedUpdates() {
count.value = 1
await nextTick() // Force flush
// Output: "Count: 1"
count.value = 2
await nextTick()
// Output: "Count: 2"
count.value = 3
// Output: "Count: 3"
}
```
**Practical example - form validation:**
```javascript
const formData = reactive({
email: '',
password: ''
})
const validationErrors = ref([])
// This watcher only fires once, with final form state
watch(formData, (data) => {
// Runs once after all fields are updated
validateForm(data)
}, { deep: true })
// When user submits, you might update multiple fields
function populateFromSavedData(saved) {
formData.email = saved.email
formData.password = saved.password
// Validation runs once with both fields set
}
```
**When batching helps performance:**
```javascript
// Without batching, this would trigger 1000 watcher/render cycles
const list = reactive([])
function addManyItems() {
for (let i = 0; i < 1000; i++) {
list.push(i)
}
}
// With batching: renders once with all 1000 items
// Without batching: would render 1000 times!
```
**Debugging intermediate states:**
```javascript
// If you need to observe every change for debugging:
import { ref, watch } from 'vue'
const count = ref(0)
// Method 1: Sync watcher (not recommended for production)
watch(count, (val) => console.log('DEBUG:', val), { flush: 'sync' })
// Method 2: Track history manually
const history = []
const originalSet = count.value
Object.defineProperty(count, 'value', {
set(val) {
history.push(val)
originalSet.call(this, val)
}
})
```
## Reference
- [Vue.js Reactivity in Depth](https://vuejs.org/guide/extras/reactivity-in-depth.html)
- [Vue.js Watchers - Callback Flush Timing](https://vuejs.org/guide/essentials/watchers.html#callback-flush-timing)
- [Vue.js nextTick()](https://vuejs.org/api/general.html#nexttick)

View File

@@ -0,0 +1,61 @@
---
title: Always Use .value When Accessing ref() in JavaScript
impact: HIGH
impactDescription: Forgetting .value causes silent failures and bugs in reactive state updates
type: capability
tags: [vue3, reactivity, ref, composition-api]
---
# Always Use .value When Accessing ref() in JavaScript
**Impact: HIGH** - Forgetting `.value` causes silent failures where state updates don't trigger reactivity, leading to hard-to-debug issues.
When using `ref()` in Vue 3's Composition API, the reactive value is wrapped in an object and must be accessed via `.value` in JavaScript code. However, in templates, Vue automatically unwraps refs so `.value` is not needed there. This inconsistency is a common source of bugs.
## Task Checklist
- [ ] Always use `.value` when reading or writing ref values in `<script>` or `.js`/`.ts` files
- [ ] Never use `.value` in `<template>` - Vue unwraps refs automatically there
- [ ] When passing refs to functions, decide whether to pass the ref object or `.value`
- [ ] Use IDE/TypeScript to catch missing `.value` errors early
**Incorrect:**
```javascript
import { ref } from 'vue'
const count = ref(0)
// These do NOT work as expected
count++ // Tries to increment the ref object, not the value
count = 5 // Reassigns the variable, loses reactivity
console.log(count) // Logs "[object Object]", not the number
const items = ref([1, 2, 3])
items.push(4) // Error: push is not a function
```
**Correct:**
```javascript
import { ref } from 'vue'
const count = ref(0)
// Always use .value in JavaScript
count.value++ // Correctly increments to 1
count.value = 5 // Correctly sets value to 5
console.log(count.value) // Logs "5"
const items = ref([1, 2, 3])
items.value.push(4) // Correctly adds 4 to the array
```
```vue
<template>
<!-- In templates, NO .value needed - Vue unwraps automatically -->
<p>{{ count }}</p>
<button @click="count++">Increment</button>
</template>
```
## Reference
- [Vue.js Reactivity Fundamentals - ref()](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#ref)

View File

@@ -0,0 +1,81 @@
---
title: Refs in Arrays and Collections Require .value
impact: MEDIUM
impactDescription: Refs inside reactive arrays, Maps, or Sets are NOT auto-unwrapped like in reactive objects
type: capability
tags: [vue3, reactivity, ref, arrays, collections, unwrapping]
---
# Refs in Arrays and Collections Require .value
**Impact: MEDIUM** - Unlike when a ref is a property of a reactive object, refs inside reactive arrays, Maps, and Sets are NOT automatically unwrapped. You must access them with `.value`, and forgetting this leads to silent bugs.
Vue only auto-unwraps refs when they are properties of reactive objects. When refs are elements in arrays or values in Maps/Sets, they remain as ref objects and require explicit `.value` access.
## Task Checklist
- [ ] Always use `.value` when accessing refs stored in reactive arrays
- [ ] Always use `.value` when accessing refs stored in reactive Maps or Sets
- [ ] Consider storing plain values instead of refs in collections to avoid confusion
- [ ] Be aware of this when iterating over arrays containing refs
**Incorrect:**
```javascript
import { ref, reactive } from 'vue'
const books = reactive([ref('Vue 3 Guide')])
const counts = reactive(new Map([['clicks', ref(0)]]))
// WRONG: Refs in arrays are NOT unwrapped
console.log(books[0]) // Ref object, not 'Vue 3 Guide'
books[0] = 'New Title' // Replaces the ref, doesn't update it!
// WRONG: Refs in Maps are NOT unwrapped
console.log(counts.get('clicks')) // Ref object, not 0
counts.get('clicks')++ // Does nothing useful
```
**Correct:**
```javascript
import { ref, reactive } from 'vue'
const books = reactive([ref('Vue 3 Guide')])
const counts = reactive(new Map([['clicks', ref(0)]]))
// CORRECT: Use .value for refs in arrays
console.log(books[0].value) // 'Vue 3 Guide'
books[0].value = 'New Title' // Updates the ref's value
// CORRECT: Use .value for refs in Maps
console.log(counts.get('clicks').value) // 0
counts.get('clicks').value++ // Increments to 1
```
```javascript
// ALTERNATIVE: Just store plain values in collections (simpler)
const books = reactive(['Vue 3 Guide', 'Vuex Handbook'])
const counts = reactive(new Map([['clicks', 0]]))
// No .value needed - but changes to individual items aren't independently reactive
console.log(books[0]) // 'Vue 3 Guide'
console.log(counts.get('clicks')) // 0
// Mutations still trigger reactivity through the reactive wrapper
books[0] = 'New Title' // Works
counts.set('clicks', counts.get('clicks') + 1) // Works
```
```vue
<template>
<!-- In templates, refs in arrays also need special handling -->
<div v-for="(book, index) in books" :key="index">
<!-- If book is a ref, you'd need: -->
{{ book.value }}
<!-- Or use computed to unwrap them first -->
</div>
</template>
```
## Reference
- [Vue.js Reactivity Fundamentals - Caveat in Arrays and Collections](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#caveat-in-arrays-and-collections)

View File

@@ -0,0 +1,151 @@
---
title: Do Not Rely on Internal VNode Properties
impact: MEDIUM
impactDescription: Using undocumented vnode properties causes code to break on Vue updates
type: gotcha
tags: [vue3, render-function, vnode, internal-api]
---
# Do Not Rely on Internal VNode Properties
**Impact: MEDIUM** - The `VNode` interface contains many internal properties used by Vue's rendering system. Relying on any properties other than the documented public ones will cause your code to break when Vue's internal implementation changes.
Only use the documented vnode properties: `type`, `props`, `children`, and `key`. All other properties are internal implementation details that may change without notice between Vue versions.
## Task Checklist
- [ ] Only access documented vnode properties: `type`, `props`, `children`, `key`
- [ ] Never access properties like `el`, `component`, `shapeFlag`, `patchFlag`, etc.
- [ ] If you need DOM element access, use template refs instead
- [ ] Treat vnodes as opaque data structures for rendering, not inspection
**Incorrect:**
```javascript
import { h } from 'vue'
export default {
setup(props, { slots }) {
return () => {
const slotContent = slots.default?.()
// WRONG: Accessing internal properties
if (slotContent?.[0]?.el) {
// el is an internal property
console.log(slotContent[0].el.tagName)
}
// WRONG: Using shapeFlag internal property
if (slotContent?.[0]?.shapeFlag & 1) {
// This is internal implementation
}
return h('div', slotContent)
}
}
}
```
```javascript
// WRONG: Inspecting component instance via vnode
const vnode = h(MyComponent)
console.log(vnode.component) // Internal property
console.log(vnode.appContext) // Internal property
```
**Correct:**
```javascript
import { h } from 'vue'
export default {
setup(props, { slots }) {
return () => {
const slotContent = slots.default?.()
// CORRECT: Only use documented properties
if (slotContent?.[0]) {
const vnode = slotContent[0]
console.log(vnode.type) // Safe: element type or component
console.log(vnode.props) // Safe: props object
console.log(vnode.children) // Safe: children
console.log(vnode.key) // Safe: key prop
}
return h('div', slotContent)
}
}
}
```
```javascript
import { h, ref, onMounted } from 'vue'
export default {
setup() {
// CORRECT: Use template refs for DOM access
const divRef = ref(null)
onMounted(() => {
// Safe way to access DOM element
console.log(divRef.value.tagName)
})
return () => h('div', { ref: divRef }, 'Content')
}
}
```
## Documented VNode Properties
| Property | Type | Description |
|----------|------|-------------|
| `type` | `string \| Component` | Element tag name or component definition |
| `props` | `object \| null` | Props passed to the vnode |
| `children` | `any` | Child vnodes, text, or slots |
| `key` | `string \| number \| null` | Key for list rendering |
## Safe VNode Inspection Patterns
```javascript
import { h, isVNode } from 'vue'
export default {
setup(props, { slots }) {
return () => {
const children = slots.default?.() || []
// Safe: Check if something is a vnode
children.forEach(child => {
if (isVNode(child)) {
// Safe: Check vnode type
if (typeof child.type === 'string') {
console.log('Element:', child.type)
} else if (typeof child.type === 'object') {
console.log('Component:', child.type.name)
}
// Safe: Read props
if (child.props?.class) {
console.log('Has class:', child.props.class)
}
}
})
return h('div', children)
}
}
}
```
## Why This Matters
Vue's internal vnode structure may change for:
- Performance optimizations
- New feature implementations
- Bug fixes
- Tree-shaking improvements
Code relying on internal properties will break silently or throw errors when upgrading Vue versions. The documented properties are part of Vue's public API and are guaranteed to remain stable.
## Reference
- [Vue.js Render Function APIs](https://vuejs.org/api/render-function.html)
- [Vue.js Render Functions - The Virtual DOM](https://vuejs.org/guide/extras/render-function.html#the-virtual-dom)

View File

@@ -0,0 +1,201 @@
---
title: Use withDirectives for Custom Directives in Render Functions
impact: LOW
impactDescription: Custom directives require special handling with withDirectives helper
type: best-practice
tags: [vue3, render-function, directives, custom-directive]
---
# Use withDirectives for Custom Directives in Render Functions
**Impact: LOW** - To apply custom directives to vnodes in render functions, use the `withDirectives` helper. Attempting to apply directives through props or other means will not work.
The `withDirectives` function wraps a vnode and applies directives with their values, arguments, and modifiers.
## Task Checklist
- [ ] Import `withDirectives` from 'vue' when using custom directives
- [ ] Import or define the directive object
- [ ] Pass directive as array: `[directive, value, argument, modifiers]`
- [ ] Multiple directives can be applied at once
**Incorrect:**
```javascript
import { h } from 'vue'
const vFocus = { mounted: (el) => el.focus() }
export default {
setup() {
return () => {
// WRONG: Directives don't work as props
return h('input', {
'v-focus': true // This doesn't work
})
}
}
}
```
**Correct:**
```javascript
import { h, withDirectives } from 'vue'
// Custom directive
const vFocus = {
mounted: (el) => el.focus()
}
export default {
setup() {
return () => {
// CORRECT: Use withDirectives
return withDirectives(
h('input'),
[[vFocus]] // Array of directive tuples
)
}
}
}
```
## Directive with Value, Argument, and Modifiers
The directive tuple format: `[directive, value, argument, modifiers]`
```javascript
import { h, withDirectives, resolveDirective } from 'vue'
// Directive definition
// Usage in template: <div v-pin:top.animate="200">
const vPin = {
mounted(el, binding) {
console.log(binding.value) // 200
console.log(binding.arg) // 'top'
console.log(binding.modifiers) // { animate: true }
el.style.position = 'fixed'
el.style[binding.arg] = binding.value + 'px'
}
}
export default {
setup() {
return () => withDirectives(
h('div', 'Pinned content'),
[
// [directive, value, argument, modifiers]
[vPin, 200, 'top', { animate: true }]
]
)
}
}
```
## Multiple Directives
```javascript
import { h, withDirectives } from 'vue'
const vFocus = { mounted: (el) => el.focus() }
const vTooltip = {
mounted(el, { value }) {
el.title = value
}
}
export default {
setup() {
return () => withDirectives(
h('input', { placeholder: 'Enter text' }),
[
[vFocus],
[vTooltip, 'This is a tooltip']
]
)
}
}
```
## Using Registered Directives
For globally or locally registered directives, use `resolveDirective`:
```javascript
import { h, withDirectives, resolveDirective } from 'vue'
// Assuming v-focus and v-tooltip are registered globally
export default {
setup() {
return () => {
const focus = resolveDirective('focus')
const tooltip = resolveDirective('tooltip')
return withDirectives(
h('input'),
[
[focus],
[tooltip, 'Helpful tip']
]
)
}
}
}
```
## Practical Example: Click Outside Directive
```javascript
import { h, withDirectives, ref } from 'vue'
const vClickOutside = {
mounted(el, binding) {
el._clickOutside = (event) => {
if (!el.contains(event.target)) {
binding.value(event)
}
}
document.addEventListener('click', el._clickOutside)
},
unmounted(el) {
document.removeEventListener('click', el._clickOutside)
}
}
export default {
setup() {
const isOpen = ref(true)
const closeDropdown = () => { isOpen.value = false }
return () => isOpen.value
? withDirectives(
h('div', { class: 'dropdown' }, 'Dropdown content'),
[[vClickOutside, closeDropdown]]
)
: null
}
}
```
## JSX with Directives
Directives in JSX also require `withDirectives`:
```jsx
import { withDirectives } from 'vue'
const vFocus = { mounted: (el) => el.focus() }
export default {
setup() {
return () => withDirectives(
<input placeholder="Search..." />,
[[vFocus]]
)
}
}
```
## Reference
- [Vue.js Render Functions - Custom Directives](https://vuejs.org/guide/extras/render-function.html#custom-directives)
- [Vue.js Custom Directives](https://vuejs.org/guide/reusability/custom-directives.html)

View File

@@ -0,0 +1,190 @@
---
title: Use withModifiers for Event Modifiers in Render Functions
impact: MEDIUM
impactDescription: Manual modifier implementation is error-prone; use withModifiers helper
type: best-practice
tags: [vue3, render-function, events, modifiers]
---
# Use withModifiers for Event Modifiers in Render Functions
**Impact: MEDIUM** - When using render functions, event modifiers like `.stop`, `.prevent`, or `.self` require special handling. Use Vue's `withModifiers` helper function instead of manually implementing modifier logic, which is error-prone.
In templates, you can use modifiers like `@click.stop.prevent`. In render functions, you must either use camelCase concatenation for simple modifiers or the `withModifiers` helper for more complex ones.
## Task Checklist
- [ ] Use camelCase concatenation for capture, once, and passive modifiers
- [ ] Use `withModifiers()` helper for stop, prevent, self, and key modifiers
- [ ] Import `withModifiers` from 'vue' when needed
- [ ] Combine multiple modifiers by passing them as an array
**Incorrect:**
```javascript
import { h } from 'vue'
export default {
setup() {
const handleClick = (e) => {
// WRONG: Manual modifier implementation is error-prone
e.stopPropagation()
e.preventDefault()
// ... actual handler logic
}
return () => h('button', { onClick: handleClick }, 'Click')
}
}
```
```javascript
// WRONG: Forgetting to handle modifier in complex scenarios
const handleDivClick = (e) => {
// Intended: only trigger when clicking div itself, not children
// This manual check is easy to get wrong
if (e.target !== e.currentTarget) return
// ...
}
```
**Correct:**
```javascript
import { h, withModifiers } from 'vue'
export default {
setup() {
const handleClick = () => {
console.log('clicked!')
}
return () => h('button',
{
// CORRECT: Use withModifiers for stop and prevent
onClick: withModifiers(handleClick, ['stop', 'prevent'])
},
'Click'
)
}
}
```
## CamelCase Modifiers (No Helper Needed)
For `capture`, `once`, and `passive` modifiers, use camelCase concatenation:
```javascript
import { h } from 'vue'
export default {
setup() {
const handler = () => console.log('event!')
return () => h('div', {
// @click.capture -> onClickCapture
onClickCapture: handler,
// @keyup.once -> onKeyupOnce
onKeyupOnce: handler,
// @scroll.passive -> onScrollPassive
onScrollPassive: handler,
// @mouseover.once.capture -> onMouseoverOnceCapture
onMouseoverOnceCapture: handler
})
}
}
```
## withModifiers Examples
```javascript
import { h, withModifiers } from 'vue'
export default {
setup() {
const handleClick = () => console.log('clicked')
const handleSubmit = () => console.log('submitted')
return () => h('div', [
// .stop modifier
h('button', {
onClick: withModifiers(handleClick, ['stop'])
}, 'Stop Propagation'),
// .prevent modifier
h('form', {
onSubmit: withModifiers(handleSubmit, ['prevent'])
}, [
h('button', { type: 'submit' }, 'Submit')
]),
// .self modifier - only trigger if event.target is the element itself
h('div', {
onClick: withModifiers(handleClick, ['self']),
style: { padding: '20px', background: '#eee' }
}, [
h('button', 'Click me (won\'t trigger parent)')
]),
// Multiple modifiers
h('a', {
href: '/path',
onClick: withModifiers(handleClick, ['stop', 'prevent'])
}, 'Link')
])
}
}
```
## Key Modifiers
For keyboard events, use `withKeys` for key modifiers:
```javascript
import { h, withKeys } from 'vue'
export default {
setup() {
const handleEnter = () => console.log('Enter pressed')
const handleEscape = () => console.log('Escape pressed')
return () => h('input', {
// @keyup.enter
onKeyup: withKeys(handleEnter, ['enter']),
// Multiple keys
onKeydown: withKeys(handleEscape, ['escape', 'esc'])
})
}
}
```
## JSX Equivalent
```jsx
import { withModifiers, withKeys } from 'vue'
export default {
setup() {
const handleClick = () => console.log('clicked')
return () => (
<div>
<button onClick={withModifiers(handleClick, ['stop'])}>
Stop
</button>
<div onClick={withModifiers(handleClick, ['self'])}>
<span>Child content</span>
</div>
<input onKeyup={withKeys(() => {}, ['enter'])} />
</div>
)
}
}
```
## Reference
- [Vue.js Render Functions - Event Modifiers](https://vuejs.org/guide/extras/render-function.html#event-modifiers)

View File

@@ -0,0 +1,171 @@
---
title: Use Functional Components for Stateless Presentational UI
impact: LOW
impactDescription: Functional components reduce overhead for simple stateless components
type: best-practice
tags: [vue3, render-function, functional-component, performance]
---
# Use Functional Components for Stateless Presentational UI
**Impact: LOW** - Functional components are plain functions that return vnodes without component instance overhead. They're ideal for simple presentational components that don't need internal state, lifecycle hooks, or reactivity.
Functional components in Vue 3 are defined as plain functions that receive `props` and a `context` object (containing `slots`, `emit`, and `attrs`). They have slightly less overhead than stateful components.
## Task Checklist
- [ ] Consider functional components for stateless UI like icons, badges, or simple layouts
- [ ] Define `props` and `emits` as static properties on the function
- [ ] Access slots, emit, and attrs from the context parameter
- [ ] Use `inheritAttrs: false` when manually spreading attrs
**Basic Functional Component:**
```javascript
import { h } from 'vue'
// Simple functional component
function MyButton(props, { slots }) {
return h('button', { class: 'btn' }, slots.default?.())
}
// With props definition
MyButton.props = ['disabled', 'variant']
export default MyButton
```
**With TypeScript:**
```typescript
import { h } from 'vue'
import type { FunctionalComponent } from 'vue'
interface Props {
message: string
type?: 'info' | 'warning' | 'error'
}
const Alert: FunctionalComponent<Props> = (props, { slots }) => {
return h('div', {
class: ['alert', `alert-${props.type || 'info'}`]
}, [
h('span', props.message),
slots.default?.()
])
}
Alert.props = {
message: { type: String, required: true },
type: String
}
export default Alert
```
**With Emits:**
```typescript
import { h } from 'vue'
import type { FunctionalComponent } from 'vue'
interface Props {
value: string
}
interface Emits {
(e: 'update', value: string): void
(e: 'clear'): void
}
const SearchInput: FunctionalComponent<Props, Emits> = (props, { emit }) => {
return h('div', { class: 'search-input' }, [
h('input', {
value: props.value,
onInput: (e: Event) => emit('update', (e.target as HTMLInputElement).value)
}),
h('button', { onClick: () => emit('clear') }, 'Clear')
])
}
SearchInput.props = ['value']
SearchInput.emits = ['update', 'clear']
export default SearchInput
```
**Disabling Attribute Inheritance:**
```javascript
import { h } from 'vue'
function CustomInput(props, { attrs }) {
return h('div', { class: 'input-wrapper' }, [
h('input', { ...attrs, class: 'custom-input' })
])
}
CustomInput.inheritAttrs = false
export default CustomInput
```
## When to Use Functional Components
**Good candidates:**
- Icons and badges
- Simple wrapper/layout components
- Pure presentational components
- Components that just format or display data
```javascript
// Icon component - good use case
function Icon(props) {
return h('svg', {
class: `icon icon-${props.name}`,
width: props.size || 24,
height: props.size || 24
}, [
h('use', { href: `#icon-${props.name}` })
])
}
Icon.props = ['name', 'size']
// Badge component - good use case
function Badge(props, { slots }) {
return h('span', {
class: ['badge', `badge-${props.variant || 'default'}`]
}, slots.default?.())
}
Badge.props = ['variant']
```
**Not recommended for:**
- Components needing reactive state (use `ref`, `reactive`)
- Components needing lifecycle hooks
- Components with complex logic
- Components that need to use composables
## Functional vs Stateful Comparison
```javascript
// Functional - no reactivity, no lifecycle
function StaticLabel(props) {
return h('span', { class: 'label' }, props.text)
}
StaticLabel.props = ['text']
// Stateful - when you need state or lifecycle
export default {
setup(props) {
const count = ref(0)
onMounted(() => {
console.log('Mounted!')
})
return () => h('button', {
onClick: () => count.value++
}, count.value)
}
}
```
## Reference
- [Vue.js Functional Components](https://vuejs.org/guide/extras/render-function.html#functional-components)

View File

@@ -0,0 +1,160 @@
---
title: Always Include key Props When Rendering Lists in Render Functions
impact: HIGH
impactDescription: Missing keys cause inefficient re-renders and state bugs in dynamic lists
type: best-practice
tags: [vue3, render-function, v-for, performance, key]
---
# Always Include key Props When Rendering Lists in Render Functions
**Impact: HIGH** - Omitting `key` props when rendering lists with `h()` or JSX causes Vue to use an inefficient "in-place patch" strategy, leading to performance issues and state bugs when list items have internal state or are reordered.
When rendering lists in render functions using `.map()`, always include a unique `key` prop on each item. This is the render function equivalent of using `:key` with `v-for` in templates.
## Task Checklist
- [ ] Always provide a `key` prop when mapping over arrays in render functions
- [ ] Use unique, stable identifiers (like `id`) for keys, not array indices
- [ ] Ensure keys are primitive values (strings or numbers)
- [ ] Never use the same key for different items in the same list
**Incorrect:**
```javascript
import { h } from 'vue'
export default {
setup() {
const items = ref([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' }
])
return () => h('ul',
// WRONG: No keys - causes inefficient patching
items.value.map(item =>
h('li', item.name)
)
)
}
}
```
```jsx
// WRONG: Using array index as key when list can be reordered
export default {
setup() {
const todos = ref([...])
return () => (
<ul>
{todos.value.map((todo, index) => (
<TodoItem
key={index} // Bad: index changes when list reorders
todo={todo}
/>
))}
</ul>
)
}
}
```
**Correct:**
```javascript
import { h } from 'vue'
export default {
setup() {
const items = ref([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' }
])
return () => h('ul',
// CORRECT: Unique id as key
items.value.map(item =>
h('li', { key: item.id }, item.name)
)
)
}
}
```
```jsx
// CORRECT: Using stable unique identifier as key
export default {
setup() {
const todos = ref([
{ id: 'a1', text: 'Learn Vue' },
{ id: 'b2', text: 'Build app' }
])
return () => (
<ul>
{todos.value.map(todo => (
<TodoItem
key={todo.id} // Good: stable unique identifier
todo={todo}
/>
))}
</ul>
)
}
}
```
```javascript
import { h } from 'vue'
export default {
setup() {
const users = ref([])
return () => h('div', [
h('h2', 'User List'),
h('ul',
users.value.map(user =>
h('li', { key: user.email }, [ // email is unique
h('span', user.name),
h('span', ` (${user.email})`)
])
)
)
])
}
}
```
## When Index Keys Are Acceptable
Using array indices as keys is acceptable ONLY when:
1. The list is static and will never be reordered
2. Items will never be inserted or removed from the middle
3. Items have no internal component state
```javascript
// Index is OK here: static list that never changes
const staticLabels = ['Name', 'Email', 'Phone']
return () => h('tr',
staticLabels.map((label, index) =>
h('th', { key: index }, label)
)
)
```
## Why Keys Matter
Without keys, Vue uses an "in-place patch" strategy:
1. It reuses DOM elements in place
2. Updates element content to match new data
3. This breaks when components have internal state or transitions
With proper keys:
1. Vue tracks each item's identity
2. Elements are moved, created, or destroyed correctly
3. Component state is preserved during reorders
## Reference
- [Vue.js Render Functions - v-for](https://vuejs.org/guide/extras/render-function.html#v-for)

View File

@@ -0,0 +1,221 @@
---
title: Implement v-model Correctly in Render Functions
impact: MEDIUM
impactDescription: Incorrect v-model implementation breaks two-way binding with components
type: best-practice
tags: [vue3, render-function, v-model, two-way-binding]
---
# Implement v-model Correctly in Render Functions
**Impact: MEDIUM** - When using `v-model` on components in render functions, you must manually handle both the `modelValue` prop and the `update:modelValue` event. Missing either part breaks two-way binding.
In templates, `v-model` is syntactic sugar that expands to a `modelValue` prop and an `update:modelValue` event listener. In render functions, you must implement this expansion manually.
## Task Checklist
- [ ] Pass `modelValue` as a prop for the bound value
- [ ] Pass `onUpdate:modelValue` handler to receive updates
- [ ] For named v-models, use the corresponding prop and event names
- [ ] Use emit in child components to trigger updates
**Incorrect:**
```javascript
import { h } from 'vue'
import CustomInput from './CustomInput.vue'
export default {
setup() {
const text = ref('')
return () => h(CustomInput, {
// WRONG: Missing the update handler
modelValue: text.value
})
}
}
```
```javascript
import { h } from 'vue'
import CustomInput from './CustomInput.vue'
export default {
setup() {
const text = ref('')
return () => h(CustomInput, {
// WRONG: Wrong event name format
modelValue: text.value,
onModelValueUpdate: (val) => text.value = val
})
}
}
```
**Correct:**
```javascript
import { h, ref } from 'vue'
import CustomInput from './CustomInput.vue'
export default {
setup() {
const text = ref('')
return () => h(CustomInput, {
// CORRECT: modelValue prop + onUpdate:modelValue handler
modelValue: text.value,
'onUpdate:modelValue': (value) => text.value = value
})
}
}
```
## Native Input Elements
For native inputs, use `value` and `onInput`:
```javascript
import { h, ref } from 'vue'
export default {
setup() {
const text = ref('')
return () => h('input', {
value: text.value,
onInput: (e) => text.value = e.target.value
})
}
}
```
## Named v-models (Multiple v-models)
```javascript
import { h, ref } from 'vue'
import UserForm from './UserForm.vue'
export default {
setup() {
const firstName = ref('')
const lastName = ref('')
return () => h(UserForm, {
// v-model:firstName
firstName: firstName.value,
'onUpdate:firstName': (val) => firstName.value = val,
// v-model:lastName
lastName: lastName.value,
'onUpdate:lastName': (val) => lastName.value = val
})
}
}
```
## v-model with Modifiers
Handle modifiers in the child component:
```javascript
import { h, ref } from 'vue'
import CustomInput from './CustomInput.vue'
// Parent - passing modifier
export default {
setup() {
const text = ref('')
return () => h(CustomInput, {
modelValue: text.value,
'onUpdate:modelValue': (val) => text.value = val,
modelModifiers: { trim: true, capitalize: true }
})
}
}
// Child - handling modifier
export default {
props: ['modelValue', 'modelModifiers'],
emits: ['update:modelValue'],
setup(props, { emit }) {
const handleInput = (e) => {
let value = e.target.value
if (props.modelModifiers?.trim) {
value = value.trim()
}
if (props.modelModifiers?.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
emit('update:modelValue', value)
}
return () => h('input', {
value: props.modelValue,
onInput: handleInput
})
}
}
```
## JSX Syntax
```jsx
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
export default {
setup() {
const text = ref('')
const count = ref(0)
return () => (
<div>
{/* v-model on custom component */}
<CustomInput
modelValue={text.value}
onUpdate:modelValue={(val) => text.value = val}
/>
{/* v-model on native input */}
<input
value={text.value}
onInput={(e) => text.value = e.target.value}
/>
{/* Named v-model */}
<Counter
count={count.value}
onUpdate:count={(val) => count.value = val}
/>
</div>
)
}
}
```
## Creating v-model-compatible Components
```javascript
import { h } from 'vue'
export default {
props: {
modelValue: String
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => h('input', {
value: props.modelValue,
onInput: (e) => emit('update:modelValue', e.target.value)
})
}
}
```
## Reference
- [Vue.js Render Functions - v-model](https://vuejs.org/guide/extras/render-function.html#v-model)
- [Vue.js Component v-model](https://vuejs.org/guide/components/v-model.html)

View File

@@ -0,0 +1,133 @@
---
title: VNodes Must Be Unique in Render Functions
impact: HIGH
impactDescription: Reusing vnode references causes rendering bugs and unexpected behavior
type: gotcha
tags: [vue3, render-function, vnode, composition-api]
---
# VNodes Must Be Unique in Render Functions
**Impact: HIGH** - Reusing the same vnode reference multiple times in a render function tree causes rendering bugs, where only one instance appears or updates behave unexpectedly.
Every vnode in a component's render tree must be unique. You cannot use the same vnode object multiple times. If you need to render the same element multiple times, create each vnode separately using a factory function or by calling `h()` in a loop.
## Task Checklist
- [ ] Never store a vnode in a variable and use it multiple times in the same tree
- [ ] Use a factory function or `.map()` to create multiple similar vnodes
- [ ] Each `h()` call creates a new vnode, so call it for each instance needed
- [ ] Be especially careful when extracting vnode creation into helper functions
**Incorrect:**
```javascript
import { h } from 'vue'
export default {
setup() {
return () => {
// WRONG: Same vnode reference used twice
const p = h('p', 'Hello')
return h('div', [p, p]) // Bug! Duplicate vnode reference
}
}
}
```
```javascript
import { h } from 'vue'
export default {
setup() {
return () => {
// WRONG: Reusing vnode in different parts of tree
const icon = h('span', { class: 'icon' }, '★')
return h('div', [
h('button', [icon, ' Save']), // Uses icon
h('button', [icon, ' Delete']) // Reuses same icon - Bug!
])
}
}
}
```
**Correct:**
```javascript
import { h } from 'vue'
export default {
setup() {
return () => {
// CORRECT: Create new vnode for each use
return h('div', [
h('p', 'Hello'),
h('p', 'Hello')
])
}
}
}
```
```javascript
import { h } from 'vue'
export default {
setup() {
return () => {
// CORRECT: Factory function creates new vnode each time
const createIcon = () => h('span', { class: 'icon' }, '★')
return h('div', [
h('button', [createIcon(), ' Save']),
h('button', [createIcon(), ' Delete'])
])
}
}
}
```
```javascript
import { h } from 'vue'
export default {
setup() {
return () => {
// CORRECT: Using map to create multiple vnodes
return h('div',
Array.from({ length: 20 }).map(() => h('p', 'Hello'))
)
}
}
}
```
```javascript
import { h } from 'vue'
export default {
setup() {
const items = ['Apple', 'Banana', 'Cherry']
return () => h('ul',
// CORRECT: Each iteration creates a new vnode
items.map((item, index) =>
h('li', { key: index }, item)
)
)
}
}
```
## Why VNodes Must Be Unique
VNodes are lightweight JavaScript objects that Vue's virtual DOM algorithm uses for diffing and patching. When the same vnode reference appears multiple times:
- Vue cannot differentiate between the instances
- The diffing algorithm produces incorrect results
- Only one instance may render, or updates may corrupt the DOM
Each vnode maintains its own identity and position in the tree, which is essential for:
- Correct DOM patching during updates
- Proper lifecycle hook execution
- Accurate key-based reconciliation in lists
## Reference
- [Vue.js Render Functions - Vnodes Must Be Unique](https://vuejs.org/guide/extras/render-function.html#vnodes-must-be-unique)

View File

@@ -0,0 +1,163 @@
---
title: Avoid Excessive Re-renders from Misused Watchers
impact: HIGH
impactDescription: Using watch instead of computed, or deep watchers unnecessarily, triggers excessive component re-renders
type: gotcha
tags: [vue3, rendering, performance, watch, computed, reactivity, re-renders]
---
# Avoid Excessive Re-renders from Misused Watchers
**Impact: HIGH** - Improper use of watchers is a common cause of performance issues. Deep watchers track every nested property change, and using watch when computed would suffice creates unnecessary reactive updates that trigger re-renders.
## Task Checklist
- [ ] Use `computed` for derived values, not `watch` + manual state updates
- [ ] Avoid `deep: true` on large objects unless absolutely necessary
- [ ] When deep watching is needed, watch specific nested paths instead
- [ ] Never trigger state changes inside watch that cause the watch to re-fire
**Incorrect:**
```vue
<script setup>
import { ref, watch } from 'vue'
const user = ref({ name: 'John', settings: { theme: 'dark', notifications: true } })
const displayName = ref('')
// BAD: Using watch to compute a derived value
// This triggers an extra reactive update cycle
watch(() => user.value.name, (name) => {
displayName.value = `User: ${name}`
}, { immediate: true })
// BAD: Deep watcher on a large object
// Fires on ANY nested change, even unrelated ones
const items = ref([/* 1000 items with nested properties */])
watch(items, (newItems) => {
console.log('Items changed') // Fires on every tiny change
}, { deep: true })
</script>
```
**Correct:**
```vue
<script setup>
import { ref, computed, watch } from 'vue'
const user = ref({ name: 'John', settings: { theme: 'dark', notifications: true } })
// GOOD: Use computed for derived values
// No extra reactive updates, automatically cached
const displayName = computed(() => `User: ${user.value.name}`)
// GOOD: Watch specific paths, not the entire object
const items = ref([/* 1000 items */])
watch(
() => items.value.length, // Only watch the length
(newLength) => {
console.log(`Items count: ${newLength}`)
}
)
// GOOD: Watch specific nested property
watch(
() => user.value.settings.theme,
(newTheme) => {
applyTheme(newTheme) // Side effect - appropriate for watch
}
)
</script>
```
## When to Use Watch vs Computed
| Use Case | Use This |
|----------|----------|
| Derive a value from state | `computed` |
| Format/transform data for display | `computed` |
| Perform side effects (API calls, DOM updates) | `watch` |
| React to route changes | `watch` |
| Sync with external systems | `watch` |
## Infinite Loop from Watch
```vue
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
// DANGER: Infinite loop!
watch(count, (newVal) => {
count.value = newVal + 1 // Modifies watched source -> triggers watch again
})
// CORRECT: Use computed or avoid self-modification
const doubleCount = computed(() => count.value * 2)
</script>
```
## Efficient Deep Watching
When you must watch complex objects:
```vue
<script setup>
import { ref, watch, toRaw } from 'vue'
const formData = ref({
personal: { name: '', email: '' },
address: { street: '', city: '' },
preferences: { /* many properties */ }
})
// BAD: Watches everything, including preferences changes
watch(formData, () => {
saveForm()
}, { deep: true })
// GOOD: Watch only the sections you care about
watch(
() => formData.value.personal,
() => savePersonalSection(),
{ deep: true } // Deep only on this small subtree
)
// GOOD: Watch serialized version for change detection
watch(
() => JSON.stringify(formData.value),
() => {
markFormDirty()
}
)
</script>
```
## Array Mutation Gotcha
```vue
<script setup>
import { ref, watch } from 'vue'
const items = ref([1, 2, 3])
// This watch won't trigger on sort/reverse without deep!
watch(items, () => {
console.log('Items changed')
})
items.value.sort() // Watch doesn't fire - array reference unchanged
// Solution 1: Use deep (performance cost)
watch(items, callback, { deep: true })
// Solution 2: Replace array instead of mutating
items.value = [...items.value].sort()
</script>
```
## Reference
- [Vue.js Watchers](https://vuejs.org/guide/essentials/watchers.html)
- [Vue.js Computed Properties](https://vuejs.org/guide/essentials/computed.html)
- [Vue.js Performance - Reactivity](https://vuejs.org/guide/best-practices/performance.html#reduce-reactivity-overhead-for-large-immutable-structures)

View File

@@ -0,0 +1,125 @@
---
title: Prefer Templates Over Render Functions for Compiler Optimizations
impact: MEDIUM
impactDescription: Render functions bypass Vue's compile-time optimizations, resulting in more work during updates
type: best-practice
tags: [vue3, rendering, performance, templates, render-functions, optimization]
---
# Prefer Templates Over Render Functions for Compiler Optimizations
**Impact: MEDIUM** - Vue's template compiler applies automatic optimizations (static hoisting, patch flags, tree flattening) that are impossible with hand-written render functions. Using render functions means every vnode must be traversed and diffed on every update, even if nothing changed.
Only use render functions when you genuinely need full JavaScript power for highly dynamic component logic.
## Task Checklist
- [ ] Default to templates for all components
- [ ] Only use render functions for highly dynamic rendering logic (e.g., dynamic tag selection)
- [ ] If using render functions, understand you're opting out of compile-time optimizations
- [ ] Consider JSX if you need render function power with better readability
## Why Templates Are Faster
Vue's compiler analyzes templates and generates optimized render functions with:
1. **Static Hoisting**: Static elements are created once and reused across re-renders
2. **Patch Flags**: Dynamic bindings are marked so Vue only checks what can change
3. **Tree Flattening**: Only dynamic nodes are traversed during reconciliation
**Incorrect:**
```vue
<script setup>
import { h, ref } from 'vue'
const count = ref(0)
const items = ref(['a', 'b', 'c'])
// BAD: Hand-written render function - no compile-time optimizations
// Every render: all vnodes created anew, all nodes diffed
const render = () => h('div', [
h('header', { class: 'static-header' }, 'My App'), // Static but recreated every render
h('p', `Count: ${count.value}`),
h('ul', items.value.map(item => h('li', { key: item }, item)))
])
</script>
```
**Correct:**
```vue
<template>
<div>
<!-- GOOD: Static header hoisted, never recreated or diffed -->
<header class="static-header">My App</header>
<!-- GOOD: Patch flag marks only text as dynamic -->
<p>Count: {{ count }}</p>
<!-- GOOD: Only list items are tracked as dynamic -->
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
const items = ref(['a', 'b', 'c'])
</script>
```
## When Render Functions Are Appropriate
Use render functions when:
- Rendering logic is highly dynamic (dynamic component types based on data)
- Building reusable library components with complex slot forwarding
- Need programmatic control that templates cannot express
```js
// ACCEPTABLE: Dynamic component type based on data
function render() {
return h(props.level > 2 ? 'h3' : 'h' + props.level, slots.default?.())
}
// ACCEPTABLE: Complex library component with dynamic children manipulation
function render() {
const children = slots.default?.() || []
return h('div', children.filter(shouldRender).map(wrapChild))
}
```
## Compiler Optimizations You Lose
| Optimization | With Templates | With Render Functions |
|--------------|---------------|----------------------|
| Static Hoisting | Automatic | Manual (you must do it) |
| Patch Flags | Automatic | None |
| Tree Flattening | Automatic | None |
| SSR Hydration Optimization | Full | Partial |
| Block-level fast paths | Yes | No |
## If You Must Use Render Functions
```js
import { h, ref } from 'vue'
// Manually hoist static vnodes outside component
const staticHeader = h('header', { class: 'static' }, 'Title')
export default {
setup() {
const count = ref(0)
return () => h('div', [
staticHeader, // Reused, not recreated
h('p', `Count: ${count.value}`)
])
}
}
```
## Reference
- [Vue.js Rendering Mechanism - Templates vs Render Functions](https://vuejs.org/guide/extras/rendering-mechanism.html#templates-vs-render-functions)
- [Vue.js Render Functions & JSX](https://vuejs.org/guide/extras/render-function.html)

View File

@@ -0,0 +1,148 @@
---
title: Import h Globally in Vue 3 Render Functions
impact: HIGH
impactDescription: Vue 3 requires explicit h import; using Vue 2 patterns causes runtime errors
type: gotcha
tags: [vue3, render-function, migration, h, vnode, breaking-change]
---
# Import h Globally in Vue 3 Render Functions
**Impact: HIGH** - In Vue 2, the `h` function (createElement) was passed as an argument to render functions. In Vue 3, `h` must be explicitly imported from 'vue'. Using Vue 2 patterns causes runtime errors.
## Task Checklist
- [ ] Import `h` from 'vue' at the top of files using render functions
- [ ] Remove the `h` parameter from render function signatures
- [ ] Update all render functions when migrating from Vue 2
**Incorrect (Vue 2 pattern - broken in Vue 3):**
```js
// WRONG: Vue 2 pattern - h is not passed as argument in Vue 3
export default {
render(h) { // h is undefined in Vue 3!
return h('div', [
h('span', 'Hello')
])
}
}
// WRONG: Using createElement alias from Vue 2
export default {
render(createElement) { // Also undefined
return createElement('div', 'Hello')
}
}
```
**Correct (Vue 3 pattern):**
```js
// CORRECT: Import h from vue
import { h } from 'vue'
export default {
render() {
return h('div', [
h('span', 'Hello')
])
}
}
```
## With Composition API
```js
import { h, ref } from 'vue'
export default {
setup() {
const count = ref(0)
// Return a render function from setup
return () => h('div', [
h('button', { onClick: () => count.value++ }, `Count: ${count.value}`)
])
}
}
```
## With script setup (Not Recommended)
```vue
<script setup>
import { h, ref } from 'vue'
const count = ref(0)
// Cannot return render function from script setup
// Must use a separate render option or template
</script>
<!-- script setup typically uses templates, not render functions -->
<template>
<div>
<button @click="count++">Count: {{ count }}</button>
</div>
</template>
```
If you need render functions with `<script setup>`, use the `render` option:
```vue
<script>
import { h, ref } from 'vue'
export default {
setup() {
const count = ref(0)
return () => h('button', { onClick: () => count.value++ }, count.value)
}
}
</script>
```
## Component Resolution Change
In Vue 3, you must also explicitly resolve components:
**Incorrect:**
```js
// Vue 2: Could use string names for registered components
render(h) {
return h('my-component', { props: { value: 1 } })
}
```
**Correct:**
```js
import { h, resolveComponent } from 'vue'
export default {
render() {
// Must resolve component by name
const MyComponent = resolveComponent('my-component')
return h(MyComponent, { value: 1 })
}
}
// Or import the component directly (preferred)
import { h } from 'vue'
import MyComponent from './MyComponent.vue'
export default {
render() {
return h(MyComponent, { value: 1 })
}
}
```
## Why This Changed
Vue 3's `h` is globally importable to:
1. Enable tree-shaking (unused features can be removed)
2. Support better TypeScript inference
3. Allow use outside of component context
## Reference
- [Vue 3 Migration Guide - Render Function API](https://v3-migration.vuejs.org/breaking-changes/render-function-api.html)
- [Vue.js Render Functions & JSX](https://vuejs.org/guide/extras/render-function.html)

View File

@@ -0,0 +1,148 @@
---
title: Return Render Function from setup(), Not Direct VNodes
impact: HIGH
impactDescription: Returning a vnode directly from setup makes it static; returning a function enables reactive updates
type: gotcha
tags: [vue3, render-function, composition-api, setup, reactivity]
---
# Return Render Function from setup(), Not Direct VNodes
**Impact: HIGH** - When using render functions with the Composition API, you must return a function that returns vnodes, not the vnodes directly. Returning vnodes directly creates a static render that never updates when reactive state changes.
## Task Checklist
- [ ] Always return an arrow function from setup() when using render functions
- [ ] Never return h() calls directly from setup()
- [ ] Ensure reactive values are accessed inside the returned function
**Incorrect:**
```js
import { h, ref } from 'vue'
export default {
setup() {
const count = ref(0)
const increment = () => count.value++
// WRONG: Returns a static vnode, created once
// Clicking the button updates count.value, but the DOM never changes!
return h('div', [
h('p', `Count: ${count.value}`), // Captures count.value at setup time (0)
h('button', { onClick: increment }, 'Increment')
])
}
}
```
**Correct:**
```js
import { h, ref } from 'vue'
export default {
setup() {
const count = ref(0)
const increment = () => count.value++
// CORRECT: Returns a render function
// Vue calls this function on every reactive update
return () => h('div', [
h('p', `Count: ${count.value}`), // Re-evaluated each render
h('button', { onClick: increment }, 'Increment')
])
}
}
```
## Why This Happens
```js
// What Vue does internally:
// WRONG approach - setup runs once:
const result = setup()
// result is a vnode { type: 'div', children: [...] }
// Vue renders this once, then has no way to re-render
// CORRECT approach - setup returns a function:
const renderFn = setup()
// renderFn is () => h('div', ...)
// Vue calls renderFn() on mount
// Vue calls renderFn() again whenever dependencies change
```
## Common Mistake: Mixing Template and Render Function
```vue
<script setup>
import { h, ref } from 'vue'
const count = ref(0)
// WRONG: Can't use render functions in script setup with templates
// This h() call does nothing
const node = h('div', count.value)
</script>
<template>
<!-- Template is used, render function is ignored -->
<div>{{ count }}</div>
</template>
```
If you need a render function with Composition API, don't use `<script setup>`:
```vue
<script>
import { h, ref } from 'vue'
export default {
setup() {
const count = ref(0)
return () => h('div', count.value)
}
}
</script>
<!-- No template - render function is used -->
```
## Exposing Values While Using Render Functions
```js
import { h, ref } from 'vue'
export default {
setup(props, { expose }) {
const count = ref(0)
const reset = () => { count.value = 0 }
// Expose methods for parent refs
expose({ reset })
// Still return the render function
return () => h('div', count.value)
}
}
```
## With Slots
```js
import { h, ref } from 'vue'
export default {
setup(props, { slots }) {
const count = ref(0)
return () => h('div', [
h('p', `Count: ${count.value}`),
// Slots must also be called inside the render function
slots.default?.()
])
}
}
```
## Reference
- [Vue.js Render Functions with Composition API](https://vuejs.org/guide/extras/render-function.html#render-functions-jsx)
- [Vue.js Composition API setup()](https://vuejs.org/api/composition-api-setup.html)

View File

@@ -0,0 +1,168 @@
---
title: Pass Slots as Functions in Render Functions, Not Direct Children
impact: HIGH
impactDescription: Passing slot content incorrectly causes slots to not render or be treated as props
type: gotcha
tags: [vue3, render-function, slots, children, vnode]
---
# Pass Slots as Functions in Render Functions, Not Direct Children
**Impact: HIGH** - When creating component vnodes with `h()`, children must be passed as slot functions, not as direct children. Passing children directly may cause them to be interpreted as props or fail to render.
## Task Checklist
- [ ] Pass slot content as functions: `{ default: () => [...] }`
- [ ] Use `null` for props when only passing slots to avoid misinterpretation
- [ ] For default slot only, a single function can be passed directly
- [ ] For named slots, use an object with slot function properties
**Incorrect:**
```js
import { h } from 'vue'
import MyComponent from './MyComponent.vue'
// WRONG: Children array may be misinterpreted
h(MyComponent, [
h('span', 'Slot content') // May not render as expected
])
// WRONG: Named slots as direct properties
h(MyComponent, {
header: h('h1', 'Title'), // This is a prop, not a slot!
default: h('p', 'Content') // This is also a prop
})
```
**Correct:**
```js
import { h } from 'vue'
import MyComponent from './MyComponent.vue'
// CORRECT: Default slot as function
h(MyComponent, null, {
default: () => h('span', 'Slot content')
})
// CORRECT: Single default slot shorthand
h(MyComponent, null, () => h('span', 'Slot content'))
// CORRECT: Named slots as functions
h(MyComponent, null, {
header: () => h('h1', 'Title'),
default: () => h('p', 'Content'),
footer: () => [
h('span', 'Footer item 1'),
h('span', 'Footer item 2')
]
})
// CORRECT: With props AND slots
h(MyComponent, { size: 'large' }, {
default: () => 'Button Text'
})
```
## Why Functions?
Slots in Vue 3 are functions for lazy evaluation:
```js
// Slots are called by the child component when needed
// This enables:
// 1. Scoped slots (passing data back)
// 2. Conditional rendering (slot not called if not used)
// 3. Proper reactivity tracking
h(MyList, { items }, {
// Scoped slot - receives data from child
item: ({ item, index }) => h('li', `${index}: ${item.name}`)
})
```
## The null Props Gotcha
When passing only slots, always use `null` for props:
```js
// WRONG: Slots object interpreted as props!
h(MyComponent, {
default: () => 'Hello'
})
// MyComponent receives: props.default = () => 'Hello'
// CORRECT: null signals "no props, next arg is slots"
h(MyComponent, null, {
default: () => 'Hello'
})
// MyComponent receives slot correctly
```
## Forwarding Slots from Parent
```js
export default {
setup(props, { slots }) {
return () => h(ChildComponent, null, {
// Forward all slots from parent
...slots,
// Or forward specific slots
default: slots.default,
header: slots.header
})
}
}
```
## Scoped Slots in Render Functions
```js
// Parent: Receives data from child via slot props
h(DataTable, { data: items }, {
row: (slotProps) => h('tr', [
h('td', slotProps.item.name),
h('td', slotProps.item.value)
])
})
// Child (DataTable): Calls slot with data
export default {
props: ['data'],
setup(props, { slots }) {
return () => h('table', [
h('tbody',
props.data.map(item =>
// Pass data to slot function
slots.row?.({ item })
)
)
])
}
}
```
## Common Patterns
```js
// Wrapper component forwarding slots
h('div', { class: 'wrapper' }, [
h(InnerComponent, null, slots)
])
// Conditional slot rendering
h('div', [
slots.header?.(), // Optional chaining - only render if slot provided
h('main', slots.default?.()),
slots.footer?.()
])
// Slot with fallback content
h('div', [
slots.default?.() ?? h('p', 'Default content when slot not provided')
])
```
## Reference
- [Vue.js Render Functions - Passing Slots](https://vuejs.org/guide/extras/render-function.html#passing-slots)
- [Vue.js Render Functions - Children](https://vuejs.org/guide/extras/render-function.html#children)

Some files were not shown because too many files have changed in this diff Show More