05 — Components Basics

SFC Compilation — script setup Macros

UserProfile.vue
<script setup lang="ts">
// ── defineProps, defineEmits, defineExpose are COMPILER MACROS ──
// They look like functions but are resolved at BUILD time by the Vue SFC compiler.
// No runtime import needed — they're not real functions, they're compiler directives.
// The compiler transforms them into setup() return values.

// ── 1. defineProps — typed props via generic ──
const props = defineProps<{
  userId: number
  role?: 'admin' | 'user' | 'guest'  // optional union
  tags?: string[]
}>()

// ── withDefaults: provide defaults for optional props (TS-only) ──
// Required because TS interface defaults don't exist at runtime.
const { role = 'user', tags = [] } = withDefaults(props, {
  role: 'user',
  tags: () => [],  // factory function for non-primitive defaults (fresh per instance)
})

// ── 2. defineEmits — typed events ──
const emit = defineEmits<{
  (e: 'update', userId: number): void           // named event with payload
  (e: 'delete', payload: { id: number; soft: boolean }): void
  (e: 'cancel'): void                            // event with no payload
}>()

// ── 3. defineExpose — explicitly expose internals to parent via template refs ──
// <script setup> components are CLOSED by default — parent ref only gets the DOM.
// defineExpose opens a controlled API surface.
function save() { /* ... */ }
function reset() { /* ... */ }
defineExpose({ save, reset })
// Parent: const childRef = ref(); childRef.value?.save()
</script>

<template>
  <div>
    <p>User: {{ userId }}</p>
    <p>Role: {{ role }}</p>
    <button @click="emit('update', userId)">Update</button>
  </div>
</template>

Prop Validation and Default Factories

prop-validation.js
// ── Runtime prop validation (Options API style, still works in <script setup>) ──
// Use when you need runtime validation (not just TS types).
const props = defineProps({
  // type: constructor — Vue checks instanceof at runtime
  userId: Number,                    // must be a number

  // type + required + default
  role: {
    type: String,
    required: false,
    default: 'user',
    // validator runs AFTER type check; receives the raw value (not default)
    validator: (v) => ['admin', 'user', 'guest'].includes(v),
  },

  // ── Object/array defaults MUST be factory functions ──
  // ❌ WRONG: default: [] — shared by reference across all instances (like data() bug)
  // ✅ CORRECT: factory returns a fresh copy per instance
  tags: {
    type: Array,
    default: () => [],               // fresh array per instance
  },

  config: {
    type: Object,
    default: () => ({ theme: 'dark', lang: 'en' }),  // fresh object per instance
  },

  // ── Multiple types: type can be an array of constructors ──
  id: [Number, String],              // accepts 42 or "42"

  // ── Boolean has special casting rules ──
  disabled: Boolean,
  // <MyComp disabled />         → true (attribute present)
  // <MyComp :disabled="false" /> → false (explicit binding)
  // <MyComp />                  → false (absent, uses default if set)
})

Attribute Fallthrough — $attrs on Fragments

Fallthrough.vue
<script setup>
// ── $attrs: non-prop attributes passed from parent ──
// Includes: class, style, id, data-*, aria-*, custom attributes
// Excludes: props declared in defineProps, emits declared in defineEmits

// ── Single root node: $attrs auto-applied to the root element ──
</script>

<template>
  <!-- Parent: <MyInput class="big" data-test="email" placeholder="Email" />
       If `placeholder` is NOT a declared prop, it lands in $attrs.
       Vue auto-applies $attrs to this <input> (the single root). -->
  <input class="base-input" />
  <!-- Result: <input class="base-input big" data-test="email" placeholder="Email" /> -->
</template>
FragmentFallthrough.vue
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()  // access $attrs in script (equivalent to $attrs in template)
</script>

<template>
  <!-- ── Fragment (multiple roots): $attrs does NOT auto-apply ── -->
  <!-- Vue can't know which root to put attrs on → must bind manually -->
  <div class="wrapper">
    <label>{{ attrs.label || 'Input' }}</label>
    <!-- Explicitly forward $attrs to the input -->
    <input v-bind="$attrs" class="base-input" />
  </div>

  <!-- ── Disable inheritance entirely with inheritAttrs: false ── -->
  <!-- Useful when you want full control over where attrs go -->
</template>
NoInherit.vue
<script setup>
// ── inheritAttrs: false — disable auto-fallthrough ──
// $attrs still exists in the object, just not auto-applied to root.
defineOptions({ inheritAttrs: false })
// Now the parent's class/style/data-* go NOWHERE unless you bind $attrs explicitly.
// Useful: wrapper components that split attrs across multiple children.
</script>

<template>
  <div class="outer">
    <input v-bind="$attrs" />  <!-- only the input gets parent attrs -->
    <span class="hint">{{ $attrs.hint }}</span>  <!-- read individual attr -->
  </div>
</template>

Dynamic Components — with KeepAlive Caching

DynamicTabs.vue
<script setup>
import { ref, shallowRef, markRaw, defineAsyncComponent } from 'vue'
import Overview from './Overview.vue'
import Settings from './Settings.vue'

// ── shallowRef for component refs — avoid deep reactivity on component objects ──
// Components are complex objects with internal state — reactive() wrapping them
// is wasteful and can break internal Vue mechanisms. Use shallowRef or markRaw.
const tabs = shallowRef({
  overview: markRaw(Overview),
  settings: markRaw(Settings),
  // ── Async component: loaded on demand, code-split into separate chunk ──
  reports: defineAsyncComponent(() => import('./Reports.vue')),
})

const activeTab = ref('overview')
</script>

<template>
  <!-- :is binds to a component definition (object, string name, or async) -->
  <!-- KeepAlive caches the component instance when it's toggled out →
       state (form inputs, scroll position) preserved on return -->
  <KeepAlive :max="3">
    <component :is="tabs[activeTab]" />
  </KeepAlive>
  <!-- :max="3": LRU cache — keeps last 3 instances, destroys older ones -->

  <button v-for="(comp, name) in tabs" :key="name" @click="activeTab = name">
    {{ name }}
  </button>
</template>

Component v-model — The Contract

CustomInput.vue
<script setup>
// ── v-model on a component expands to: ──
//   :modelValue="value" @update:modelValue="value = $event"
// Vue 3 renamed: value→modelValue, input→update:modelValue (breaking from Vue 2)

defineProps<{
  modelValue: string  // the bound value
}>()

const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void  // must emit this to update
}>()

function onInput(e) {
  emit('update:modelValue', e.target.value)  // notify parent of new value
}
</script>

<template>
  <input :value="modelValue" @input="onInput" />
</template>
MultiVModel.vue
<script setup>
// ── Multiple v-models on one component ──
// Parent: <UserForm v-model:firstName="fn" v-model:lastName="ln" />
defineProps<{
  firstName: string
  lastName: string
}>()
const emit = defineEmits(['update:firstName', 'update:lastName'])
</script>

<template>
  <input :value="firstName" @input="emit('update:firstName', $event.target.value)" />
  <input :value="lastName" @input="emit('update:lastName', $event.target.value)" />
</template>

💡 Tips & Tricks

TipsTricks.vue
<script setup>
import { ref, useTemplateRef, defineAsyncComponent } from 'vue'

// ── 1. useTemplateRef (3.5+) — typed template ref without ref(null) boilerplate ──
const inputEl = useTemplateRef<HTMLInputElement>('inputEl')
// Template: <input ref="inputEl" /> — inputEl.value is HTMLInputElement | null

// ── 2. Async component with loading/error states ──
const AsyncChart = defineAsyncComponent({
  loader: () => import('./Chart.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,       // show loading after 200ms (avoid flicker for fast loads)
  timeout: 8000,    // show error after 8s
  // suspensible: true → works with <Suspense> (defer to nearest boundary)
})

// ── 3. defineOptions — set Options API options inside script setup ──
defineOptions({
  name: 'UserCard',        // named for DevTools + keep-alive include/exclude
  inheritAttrs: false,
})

// ── 4. defineModel (3.4+) — declarative v-model without boilerplate ──
const model = defineModel<string>()  // auto-creates modelValue prop + emit
// <input v-model="model" /> — writes go through the parent binding automatically
</script>

⚠️ Edge Cases & Gotchas

edge-cases.ts
// ── 1. Object/array prop defaults must be factory functions ──
// default: [] or default: {} → shared across ALL instances (reference bug)
// default: () => [] or () => ({}) → fresh copy per instance (correct)

// ── 2. Boolean prop casting — empty string is NOT false ──
// <Comp disabled />    → disabled = true (attribute present = true)
// <Comp disabled="" /> → disabled = true (empty string still means present)
// <Comp :disabled="false" /> → disabled = false (explicit boolean binding)

// ── 3. defineProps is a compile-time macro — can't be inside functions ──
// ❌ function setup() { const props = defineProps() } — only works in <script setup> top level
// ✅ const props = defineProps() — must be top-level in <script setup>

// ── 4. defineExpose is opt-in — <script setup> is closed by default ──
// Parent template refs on a <script setup> child get null for everything
// unless the child calls defineExpose(). Options API exposes all data/methods.

// ── 5. Async components need shallowRef/markRaw, not reactive ──
// reactive() on a component definition breaks internal Vue compilation caches.
// Store component refs in shallowRef or markRaw them before assignment.

// ── 6. Prop type checks are dev-only — stripped in production ──
// defineProps<{ x: number }>() → pure TS, zero runtime check in prod.
// defineProps({ x: Number }) → runtime instanceof check, dev warning only.
// Always validate at the boundary if type safety is critical in production.

🧠 Spot the Bug

A custom checkbox component always shows the same checked state across all instances.

CheckboxBug.vue
<script setup>
defineProps({
  checked: {
    type: Array,
    default: [],  // ← shared reference across all instances
  },
})
const emit = defineEmits(['update:checked'])
</script>

<template>
  <input type="checkbox" :checked="checked.includes('x')" />
</template>
Answer

default: [] creates one array at module evaluation time and shares it across every component instance that uses the default. When one instance pushes to it, all others see the mutation.

Fix — use a factory function:

checkbox-fix.js
defineProps({
  checked: {
    type: Array,
    default: () => [],  // fresh array per instance — no shared reference
  },
})

The lesson: object/array prop defaults must be factory functions returning a fresh copy. Static defaults share one reference across all instances, causing cross-instance state leaks identical to the Vue 2 data: {} bug.