Vue 3 in depth

Component design

Props, emits and defineModel, slots and their types, attribute fallthrough, provide/inject, defineExpose and generic components, designed so that a team cannot misuse them.

A senior frontend engineer is judged on the components other people have to use. The interview version of that is "design a Select for the team" or "review this component's API", and what they listen for is whether you know the primitives (props, emits, defineModel, slots, attrs, provide/inject, defineExpose) well enough to choose the right one, and whether you can explain the contract each one creates.

Know

  • Props flow down and are readonly. Type them with defineProps<{ ... }>(). Defaults come from withDefaults or, since Vue 3.5, from reactive props destructure: const { size = 'md' } = defineProps<Props>() stays reactive because the compiler rewrites size to props.size. Pass a destructured prop to watch as a getter (watch(() => size, ...)), not as a value.
  • Emits are the component's events contract. defineEmits<{ select: [id: string], 'update:open': [value: boolean] }>() types the payloads. Declared events are removed from $attrs; undeclared listeners fall through to the root element, which is how native events on a wrapper keep working.
  • defineModel() (3.4+) replaces the modelValue prop plus update:modelValue emit boilerplate. It supports names (defineModel('title')), options ({ required, default }), modifiers (const [value, modifiers] = defineModel()), and a local fallback: when the parent does not bind it, the ref still works, which gives you controlled and uncontrolled behaviour from one implementation.
  • Slots are functions. Named and scoped slots let the consumer own rendering while the component owns behaviour. Type them with defineSlots<{ item(props: { row: T }): any }>(), provide fallback content inside the <slot>, and check presence with $slots.footer before rendering wrappers.
  • Attribute fallthrough. Undeclared attributes (class, style, id, data-*, listeners) land on the single root element. Multi-root components and wrappers need defineOptions({ inheritAttrs: false }) plus v-bind="$attrs" on the element that should receive them; useAttrs() reads them in script.
  • Provide/inject is the tool for compound components (Tabs/Tab, Accordion/AccordionItem) and for context that would otherwise be drilled through five levels. Use an InjectionKey<T> for typing, provide reactive state wrapped in readonly(), and give inject a default or throw a clear error when the parent is missing.
  • defineExpose defines the imperative surface of a component (focus(), open(), reset()); everything else in <script setup> is private. Template refs use useTemplateRef<InstanceType<typeof Child>>('child') (3.5) and see only the exposed members.
  • Generic components. <script setup lang="ts" generic="T extends { id: string }"> types props and slots against the same T, which is what makes a typed DataTable<Row> or Select<Option> possible.
  • Boolean casting. A boolean prop that is absent is false, not undefined, and <Comp disabled> is true. If a prop is string | boolean, declaration order in runtime props matters; with TypeScript types the compiler handles it.
  • useId() gives SSR-stable ids for label/for and aria-* associations, which removed a whole class of hydration mismatches in form components.

How it works

A team Select with the full toolkit: a generic option type, defineModel, typed slots and emits, attrs forwarded to the trigger, and a stable id:

app/components/Team/Select.vue
<script setup lang="ts" generic="T extends { value: string, label: string, disabled?: boolean }">
defineOptions({ inheritAttrs: false })

const { options, placeholder = 'Select…' } = defineProps<{
  options: T[]
  placeholder?: string
  label: string
}>()

const model = defineModel<T['value'] | null>({ default: null })
const emit = defineEmits<{ open: [], close: [] }>()

const slots = defineSlots<{
  option(props: { option: T, selected: boolean }): any
  empty(): any
}>()

const id = useId()
const open = ref(false)
const selected = computed(() => options.find(o => o.value === model.value))
const trigger = useTemplateRef<HTMLButtonElement>('trigger')

function choose(option: T) {
  if (option.disabled) return
  model.value = option.value
  open.value = false
  emit('close')
  trigger.value?.focus()
}

defineExpose({ focus: () => trigger.value?.focus() })
</script>

<template>
  <div class="select">
    <label :for="id">{{ label }}</label>
    <button
      :id="id"
      ref="trigger"
      type="button"
      v-bind="$attrs"
      :aria-expanded="open"
      :aria-controls="`${id}-list`"
      @click="open = !open; emit(open ? 'open' : 'close')"
    >
      {{ selected?.label ?? placeholder }}
    </button>
    <ul v-if="open" :id="`${id}-list`" role="listbox">
      <li
        v-for="option in options"
        :key="option.value"
        role="option"
        :aria-selected="option.value === model"
        :aria-disabled="option.disabled || undefined"
        @click="choose(option)"
      >
        <slot name="option" :option="option" :selected="option.value === model">
          {{ option.label }}
        </slot>
      </li>
      <li v-if="!options.length" role="presentation">
        <slot name="empty">No options</slot>
      </li>
    </ul>
  </div>
</template>

A compound component through provide/inject, with a typed key and readonly state:

app/components/Team/tabs.ts
export interface TabsContext {
  active: Readonly<Ref<string>>
  select: (id: string) => void
  register: (id: string) => void
}
export const TABS_KEY: InjectionKey<TabsContext> = Symbol('team-tabs')

export function useTabsContext(): TabsContext {
  const ctx = inject(TABS_KEY)
  if (!ctx) throw new Error('<TeamTab> must be used inside <TeamTabs>')
  return ctx
}
app/components/Team/Tabs.vue
<script setup lang="ts">
import { TABS_KEY } from './tabs'

const active = defineModel<string>({ default: '' })
const ids: string[] = []

provide(TABS_KEY, {
  active: readonly(active),
  select: id => { active.value = id },
  register: (id) => {
    ids.push(id)
    if (!active.value) active.value = id
  },
})
</script>

<template>
  <div role="tablist"><slot /></div>
</template>
Gotcha· Mutating a prop, or an object behind one

props.user.name = 'x' does not warn, because only the prop binding itself is readonly, but it mutates the parent's state from below and breaks the one-way rule. Emit an event or use defineModel for two-way intent. The same applies to v-model bound to a nested object property from a child: the mutation "works" and nobody can trace who changed it.

Gotcha· Slots and Suspense

A scoped slot that renders an async component inside <Suspense> boundaries you did not expect can suspend the whole parent. Components that accept arbitrary slot content should document whether they are Suspense boundaries, and Nuxt pages already are.

Exercise

Exercise
  • Build the TeamSelect above with generic="T", then use it with two different option types and confirm the #option slot props are typed correctly in the parent.
  • Remove defineOptions({ inheritAttrs: false }) and add a class in the parent; see where it lands. Put it back and watch it move to the trigger.
  • Finish the Tabs/Tab pair: Tab registers itself on mount, renders a role="tab" button, and sets aria-selected. Use useId for aria-controls and verify the ids match in the server HTML.

Be able to say

Be able to say· How do you design a component API for a team?

"I start from the contract, not the markup. Props for configuration, typed and with defaults through reactive props destructure; defineModel for anything two-way, with a local fallback so it works controlled and uncontrolled; typed emits for events the parent must react to; slots for anything the consumer might want to render differently, typed with defineSlots; inheritAttrs: false and v-bind=\"$attrs\" on the element that should receive class and native listeners. For a family of components I use provide/inject with an InjectionKey rather than drilling props, and defineExpose only for the imperative methods I am willing to support. The test I apply is: can a consumer do the wrong thing without a type error? If yes, the API is not finished."

Be able to say· What does defineModel do and why does it matter?

"defineModel is a compiler macro that declares the modelValue prop and the update:modelValue emit together and gives you a ref that reads the prop and emits on write. It supports named models, options like required and default, and modifiers. The important behaviour is the local fallback: if the parent does not bind v-model, the ref still holds state, so one implementation is both a controlled and an uncontrolled component. It replaced a computed with a getter and a setter that every codebase had copied a hundred times."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.