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.
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.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.defineSlots<{ item(props: { row: T }): any }>(), provide fallback content inside the <slot>, and check presence with $slots.footer before rendering wrappers.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.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.<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.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.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:
<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:
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
}
<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>
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.
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.
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.defineOptions({ inheritAttrs: false }) and add a class in the parent; see where it lands. Put it back and watch it move to the trigger.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."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."
"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."
Nothing yet. Notes filed here show up on this page and the section overview.
Reactivity system
ref, reactive, shallowRef and computed underneath, watch versus watchEffect and flush timing, effect scopes, and the mistakes that make state stop updating.
Composables
The rules for writing composables that are reusable, typed, testable and SSR-safe, the conventions VueUse established, and how they differ from utils and stores.