Coding drills

Vue component drills

The component and composable builds that senior Vue interviews ask for in 30–45 minutes — a todo list with filters, an accessible autocomplete, a dialog with focus trapping, tabs, a sortable paginated table, an infinite list, a validated form — and the composables behind them (useDebounce, useLocalStorage, useIntersectionObserver, useEventListener), with the details reviewers look for.

Live Vue drills test whether you can go from a blank file to a working, accessible component quickly and cleanly. Reviewers watch for typed props and emits, defineModel, composables for logic, correct reactivity (no lost reactivity when destructuring, cleanup on unmount), keyboard support, and whether the code stays readable at minute forty. Each drill below lists the specification a strong candidate states before typing, then the core of a solution.

Know

  • Todo with filters (warm-up): typed items, v-model input, add on Enter, toggle, delete, filter (all/active/done) in the URL or local state, counts, key on items, persisted with a composable. Watch for: mutating props, index as key, filter inside the template without computed.
  • Autocomplete / combobox: debounced query, cancellation of stale requests, keyboard navigation (arrows, Enter, Escape, Home/End), role="combobox" with aria-expanded, aria-controls, aria-activedescendant, role="listbox"/option, live region for result count, highlight without XSS (no v-html on user input), click outside, loading and empty states.
  • Dialog / modal: <Teleport to="body">, role="dialog" with aria-modal and aria-labelledby, focus moved in on open and restored on close, focus trapped (Tab cycles), Escape closes, background scroll locked, inert on the app root or a focus-trap library; or the native <dialog> element with showModal(), which gives most of this for free.
  • Tabs: role="tablist"/tab/tabpanel, aria-selected, roving tabindex (only the active tab is tabbable, arrows move), aria-controls/aria-labelledby pairs, v-model for the active tab, lazy panels with <KeepAlive> when panels are expensive.
  • Data table: typed columns, sort (toSorted with localeCompare or numeric compare, stable), pagination (slice), aria-sort on headers, <th scope>, <caption>, empty and loading states, slots for cells, virtualisation when rows exceed a few hundred (@tanstack/vue-virtual or a fixed-height window).
  • Infinite list: IntersectionObserver sentinel, cursor-based loading, no duplicate loads while pending, error with retry, "back to top", scroll restoration on return (useNuxtApp().$router scrollBehavior or saving offsets).
  • Form with validation: typed model, schema (zod/valibot) shared with the server, validate on blur and submit, errors associated via aria-describedby, aria-invalid, focus the first invalid field on submit, disabled submit while pending, optimistic reset.
  • Composable expectations: accept MaybeRefOrGetter and use toValue; return refs, not reactive objects that break on destructure; clean up in onScopeDispose; be SSR-safe (import.meta.client guards, onMounted for browser APIs); provide isSupported for browser APIs; test with withSetup/effectScope.
  • VueUse covers most of these (useDebounceFn, useLocalStorage, useIntersectionObserver, useEventListener, useFocusTrap, onClickOutside); in interviews, name it and then implement the asked one anyway.

How it works

An accessible autocomplete with a debounced, cancellable fetch:

Autocomplete.vue
<script setup lang="ts" generic="T extends { id: string, label: string }">
const props = defineProps<{ fetch: (q: string, signal: AbortSignal) => Promise<T[]>, label: string, minLength?: number }>()
const emit = defineEmits<{ select: [item: T] }>()
const model = defineModel<string>({ default: '' })

const items = shallowRef<T[]>([])
const open = ref(false)
const active = ref(-1)
const loading = ref(false)
const id = useId()
let controller: AbortController | undefined

const search = useDebounceFn(async (q: string) => {
  controller?.abort()
  if (q.length < (props.minLength ?? 1)) { items.value = []; open.value = false; return }
  controller = new AbortController()
  loading.value = true
  try {
    items.value = await props.fetch(q, controller.signal)
    open.value = true
    active.value = items.value.length ? 0 : -1
  }
  catch (err) {
    if ((err as Error).name !== 'AbortError') throw err
  }
  finally {
    if (!controller?.signal.aborted) loading.value = false
  }
}, 250)

watch(model, q => search(q))

function select(item: T) {
  model.value = item.label
  open.value = false
  emit('select', item)
}

function onKeydown(e: KeyboardEvent) {
  if (!open.value) return
  const last = items.value.length - 1
  switch (e.key) {
    case 'ArrowDown': e.preventDefault(); active.value = active.value >= last ? 0 : active.value + 1; break
    case 'ArrowUp': e.preventDefault(); active.value = active.value <= 0 ? last : active.value - 1; break
    case 'Home': e.preventDefault(); active.value = 0; break
    case 'End': e.preventDefault(); active.value = last; break
    case 'Enter': if (active.value >= 0) { e.preventDefault(); select(items.value[active.value]!) } break
    case 'Escape': e.preventDefault(); open.value = false; break
  }
}

const root = useTemplateRef('root')
onClickOutside(root, () => (open.value = false))
onScopeDispose(() => controller?.abort())
</script>

<template>
  <div ref="root" class="relative">
    <label :for="`${id}-input`">{{ label }}</label>
    <input
      :id="`${id}-input`"
      v-model="model"
      role="combobox"
      autocomplete="off"
      :aria-expanded="open"
      :aria-controls="`${id}-listbox`"
      :aria-activedescendant="open && active >= 0 ? `${id}-option-${active}` : undefined"
      @keydown="onKeydown"
      @focus="items.length && (open = true)"
    >
    <ul v-show="open" :id="`${id}-listbox`" role="listbox" class="absolute z-10 w-full">
      <li
        v-for="(item, i) in items"
        :id="`${id}-option-${i}`"
        :key="item.id"
        role="option"
        :aria-selected="i === active"
        :class="{ 'bg-primary/10': i === active }"
        @mousedown.prevent="select(item)"
        @mousemove="active = i"
      >
        {{ item.label }}
      </li>
      <li v-if="!loading && !items.length" role="option" aria-disabled="true">No results</li>
    </ul>
    <p class="sr-only" role="status">{{ loading ? 'Loading' : `${items.length} results` }}</p>
  </div>
</template>

A dialog on the native element, which supplies focus trapping, Escape and the top layer:

Dialog.vue
<script setup lang="ts">
const open = defineModel<boolean>('open', { default: false })
defineProps<{ title: string }>()
const dialog = useTemplateRef('dialog')
let opener: HTMLElement | null = null

watch(open, (value) => {
  if (!dialog.value) return
  if (value) { opener = document.activeElement as HTMLElement | null; dialog.value.showModal() }
  else { dialog.value.close(); opener?.focus() }
}, { flush: 'post' })

// user pressed Escape or the form method="dialog" closed it: keep the model in sync
function onClose() { open.value = false }
</script>

<template>
  <dialog ref="dialog" :aria-labelledby="`${$.uid}-title`" class="m-auto rounded-lg p-6 backdrop:bg-black/50" @close="onClose">
    <h2 :id="`${$.uid}-title`">{{ title }}</h2>
    <slot :close="() => (open = false)" />
    <form method="dialog" class="mt-4 text-right"><button>Close</button></form>
  </dialog>
</template>

Tabs with roving tabindex and v-model:

Tabs.vue
<script setup lang="ts">
const props = defineProps<{ tabs: { id: string, label: string }[] }>()
const active = defineModel<string>({ required: true })
const id = useId()
const index = computed(() => props.tabs.findIndex(t => t.id === active.value))

function onKeydown(e: KeyboardEvent) {
  const n = props.tabs.length
  const next = { ArrowRight: (index.value + 1) % n, ArrowLeft: (index.value - 1 + n) % n, Home: 0, End: n - 1 }[e.key]
  if (next === undefined) return
  e.preventDefault()
  active.value = props.tabs[next]!.id
  ;(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[role=tab]')[next]?.focus()
}
</script>

<template>
  <div role="tablist" @keydown="onKeydown">
    <button
      v-for="tab in tabs"
      :id="`${id}-tab-${tab.id}`"
      :key="tab.id"
      role="tab"
      :aria-selected="tab.id === active"
      :aria-controls="`${id}-panel-${tab.id}`"
      :tabindex="tab.id === active ? 0 : -1"
      @click="active = tab.id"
    >
      {{ tab.label }}
    </button>
  </div>
  <div v-for="tab in tabs" :id="`${id}-panel-${tab.id}`" :key="tab.id" role="tabpanel" :aria-labelledby="`${id}-tab-${tab.id}`" :hidden="tab.id !== active" tabindex="0">
    <slot :name="tab.id" />
  </div>
</template>

A sortable, paginated table core and an infinite list sentinel:

useTable.ts
export function useTable<T>(rows: MaybeRefOrGetter<T[]>, { pageSize = 20 } = {}) {
  const sortKey = ref<keyof T | null>(null)
  const sortDir = ref<'asc' | 'desc'>('asc')
  const page = ref(1)
  const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })

  const sorted = computed(() => {
    const list = toValue(rows)
    const key = sortKey.value
    if (!key) return list
    const dir = sortDir.value === 'asc' ? 1 : -1
    return list.toSorted((a, b) => {
      const x = a[key], y = b[key]
      if (typeof x === 'number' && typeof y === 'number') return (x - y) * dir
      return collator.compare(String(x ?? ''), String(y ?? '')) * dir       // stable: toSorted preserves ties
    })
  })
  const pageCount = computed(() => Math.max(1, Math.ceil(sorted.value.length / pageSize)))
  const pageRows = computed(() => sorted.value.slice((page.value - 1) * pageSize, page.value * pageSize))
  watch(sorted, () => { page.value = 1 })

  function sortBy(key: keyof T) {
    if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
    else { sortKey.value = key; sortDir.value = 'asc' }
  }
  const ariaSort = (key: keyof T) => sortKey.value !== key ? 'none' : sortDir.value === 'asc' ? 'ascending' : 'descending'
  return { sortKey, sortDir, page, pageCount, pageRows, sortBy, ariaSort }
}
InfiniteList.vue (core)
<script setup lang="ts" generic="T extends { id: string }">
const props = defineProps<{ load: (cursor: string | null) => Promise<{ items: T[], next: string | null }> }>()
const items = shallowRef<T[]>([])
const cursor = ref<string | null>(null)
const done = ref(false)
const pending = ref(false)
const error = ref<unknown>(null)
const sentinel = useTemplateRef('sentinel')

async function more() {
  if (pending.value || done.value) return
  pending.value = true; error.value = null
  try {
    const page = await props.load(cursor.value)
    items.value = [...items.value, ...page.items]
    cursor.value = page.next
    done.value = page.next === null
  }
  catch (e) { error.value = e }
  finally { pending.value = false }
}

useIntersectionObserver(sentinel, ([entry]) => { if (entry?.isIntersecting) more() }, { rootMargin: '400px' })
</script>

<template>
  <ul><li v-for="item in items" :key="item.id"><slot :item="item" /></li></ul>
  <p v-if="error" role="alert">Could not load. <button @click="more">Retry</button></p>
  <div ref="sentinel" aria-hidden="true" />
  <p v-if="pending" role="status" class="sr-only">Loading more</p>
</template>

Composables written the way reviewers expect:

useLocalStorage.ts
export function useLocalStorage<T>(key: string, initial: T) {
  const state = ref(initial) as Ref<T>
  if (import.meta.client) {
    onMounted(() => {                                   // after hydration: server and first client render agree
      try { const raw = localStorage.getItem(key); if (raw !== null) state.value = JSON.parse(raw) } catch {}
      watch(state, v => localStorage.setItem(key, JSON.stringify(v)), { deep: true })
      useEventListener(window, 'storage', (e) => { if (e.key === key && e.newValue !== null) state.value = JSON.parse(e.newValue) }) // other tabs
    })
  }
  return state
}
useEventListener.ts
export function useEventListener<K extends keyof WindowEventMap>(target: MaybeRefOrGetter<EventTarget | null | undefined>, event: K, handler: (e: WindowEventMap[K]) => void, options?: AddEventListenerOptions) {
  if (import.meta.server) return
  watch(() => toValue(target), (el, _, onCleanup) => {
    if (!el) return
    el.addEventListener(event, handler as EventListener, options)
    onCleanup(() => el.removeEventListener(event, handler as EventListener, options))
  }, { immediate: true, flush: 'post' })
}
Gotcha· Losing reactivity in the composable's return

return reactive({ page, rows }) then const { page } = useTable() gives a plain number. Return refs (or use toRefs) so consumers can destructure, and accept MaybeRefOrGetter inputs read through toValue inside computed.

Gotcha· Highlighting matches with v-html

Wrapping the matched substring in <mark> via v-html on user-entered or API text is an XSS hole. Split the label into parts and render them with <template v-for> and a <mark> element instead.

Exercise

Exercise
  • Build the autocomplete in 40 minutes with a fake async fetch; then run axe on it and test arrows, Enter and Escape with Vitest and @vue/test-utils.
  • Rebuild the dialog with the native element and with Reka UI's Dialog; list what the native one lacks (animation hooks, nested dialogs, custom backdrop click handling).
  • Write useTable with tests for stable sorting of ties and page reset on re-sort.

Be able to say

Be able to say· Build an autocomplete component. What do you take care of?

"Debounce the input and cancel stale requests with an AbortController so a fast typist never sees results for an earlier query. Keyboard first: arrows move the active option, Enter selects, Escape closes, Home and End jump. Accessibility with the combobox pattern: the input has role=combobox, aria-expanded, aria-controls pointing at the listbox and aria-activedescendant for the active option, options have role=option and aria-selected, and a live region announces the result count. Loading, empty and error states, click-outside to close, abort on unmount, and no v-html for highlighting. I would type it generically so it works for any item shape, and keep the fetch as an injected function so it is testable without a network."

Your notes

Open in Notes

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