Vue 3 in depth

Patterns and anti-patterns

Renderless and headless components, compound components, controlled versus uncontrolled state, composable-first components, and the smells a senior spots in a code review.

"Review this component" is a favourite senior exercise because it tests judgement rather than recall. The code compiles and works; the question is what will hurt in six months. This page collects the patterns worth naming in that conversation and the anti-patterns you should be able to spot in under a minute each.

Know — patterns

  • Composable-first components. Logic lives in a composable (useDataTable), the component is a thin view over it. The composable is unit-tested without a DOM; the component test only checks wiring. This is the pattern behind Nuxt UI and VueUse.
  • Renderless components own behaviour and expose it through a scoped slot: <UseClipboard v-slot="{ copy, copied }">. Useful when the consumer must own the markup entirely; a composable is usually simpler unless you need it in a template.
  • Headless UI primitives. Reka UI (the base of Nuxt UI v4) ships accessible behaviour (focus management, keyboard navigation, ARIA) without styles. Building on primitives instead of from scratch is how a team avoids re-implementing a combobox badly.
  • Compound components (Tabs/Tab, Menu/MenuItem) share state through provide/inject with a typed key, so the consumer composes them freely in the template and the parent still coordinates.
  • Controlled and uncontrolled from one implementation via defineModel's local fallback; the same component works with or without v-model.
  • Slots over boolean props. Six booleans that toggle bits of markup are a slot in disguise: #footer, #actions, #empty. Props configure behaviour; slots configure rendering.
  • Attribute passthrough by design. Wrappers declare inheritAttrs: false and forward $attrs to the meaningful element so class, aria-* and native listeners keep working. Nuxt UI's ui prop pattern goes further and lets consumers target internal slots with classes.
  • Reset by key. <Form :key="userId"> remounts the form when the user changes, which is cleaner than a watcher that clears twelve fields.
  • Props down, events up, and a store only for genuinely global state. Cross-cutting state that many distant components read (current user, feature flags) is a store or useState; everything else stays local and travels by props and events.

Know — anti-patterns

  • Watchers computing derived state. A watch that writes total from items is a computed. Symptoms: stale values before the flush, forgotten second sources, immediate: true bolted on.
  • Mutating props, including nested objects behind a prop or v-model on props.form.name. It works and nobody can trace the write.
  • Prop drilling through four or five levels for one value. Provide/inject, a compound component or a store, depending on scope.
  • The 600-line component. Several concerns in one setup. Extract composables by feature; the component should read like a table of contents.
  • Index as key on reorderable lists; v-if and v-for on the same element (in Vue 3 v-if wins and cannot see the loop variable); v-for without a key.
  • Side effects in computed (fetching, writing to storage, emitting). Computed getters run when Vue decides.
  • Module-level state in composables for SSR apps: shared between requests. Use useState or provide from the app root.
  • document.querySelector inside components where a template ref belongs; setTimeout(fn, 0) where nextTick belongs.
  • Event buses (mitt as a global) for parent–child communication. Vue 3 removed $on for a reason; use emits, provide/inject or a store.
  • Over-generic abstractions: a BaseTable with forty props to cover every table in the app. Two similar components are cheaper than one wrong abstraction.
  • any at the boundaries: untyped emits, Record<string, any> props, as casts around $refs. The types are the API documentation.
  • Everything in a Pinia store, including a modal's open flag. Stores are global; UI state is local.

How it works

The review exercise in one file: three smells and their fixes.

app/components/Cart/Summary.vue
<script setup lang="ts">
import type { CartItem } from '#shared/types/cart'

const props = defineProps<{ items: CartItem[], currency: string }>()
const emit = defineEmits<{ remove: [id: string] }>()

// ✗ Smell 1: a watcher computing derived state.
// const total = ref(0)
// watch(() => props.items, (items) => { total.value = items.reduce((n, i) => n + i.price * i.qty, 0) }, { immediate: true, deep: true })
// ✓ A computed is lazy, cached, and never stale.
const total = computed(() => props.items.reduce((n, i) => n + i.price * i.qty, 0))

// ✗ Smell 2: mutating a prop.
// function remove(id: string) { props.items.splice(props.items.findIndex(i => i.id === id), 1) }
// ✓ Ask the owner of the state to change it.
function remove(id: string) {
  emit('remove', id)
}

// ✗ Smell 3: formatting in the template with a global helper and a locale from a store.
// ✓ A memoised formatter next to the data it formats.
const format = computed(() => new Intl.NumberFormat(undefined, { style: 'currency', currency: props.currency }))
</script>

<template>
  <ul>
    <!-- ✗ :key="index" would move row state when an item is removed -->
    <li v-for="item in items" :key="item.id">
      {{ item.name }} × {{ item.qty }}
      <button type="button" @click="remove(item.id)">Remove</button>
    </li>
  </ul>
  <p>Total: {{ format.format(total) }}</p>
</template>

A renderless component and the composable it wraps, to show that one is a thin shell over the other:

app/components/Use/Clipboard.vue
<script setup lang="ts">
const { copy, copied, isSupported } = useClipboard({ copiedDuring: 1500 })
defineSlots<{ default(props: { copy: (text: string) => Promise<void>, copied: boolean, isSupported: boolean }): any }>()
</script>

<template>
  <slot :copy="copy" :copied="copied" :is-supported="isSupported" />
</template>
Gotcha· The wrong abstraction is more expensive than duplication

A component that grew a variant, then a mode, then hideHeader, then customFooterSlotName, is telling you it is two components. Splitting it is a refactor; keeping it is a permanent tax on every consumer. Interviewers like hearing that you have deleted an abstraction.

Exercise

Exercise
  • Take one of your own components over 300 lines and list its concerns. Extract one concern into a composable with a test; measure how much the component shrank.
  • Rebuild a modal you have written on top of Reka UI's DialogRoot/DialogContent (or Nuxt UI's UModal) and compare the keyboard and focus behaviour with your original.
  • Review the Cart/Summary.vue above out loud as if in an interview: name each smell, the failure it causes and the fix, in under two minutes.

Be able to say

Be able to say· What do you look for when reviewing a Vue component?

"Whether state lives in the right place and flows in one direction: props down, events up, no mutated props, derived values as computeds rather than watchers. Whether the component is thin over a composable so the logic is testable without a DOM. Whether the list keys are stable ids. Whether the API is typed at the edges: emits, slots, and no any. Whether the component is trying to be two components, which shows up as booleans that toggle chunks of markup where slots belong. And for SSR, whether anything touches window outside onMounted or keeps state at module level."

Be able to say· When would you write a renderless component instead of a composable?

"Almost never for new code: a composable does the same job with less ceremony and works outside templates. The exceptions are when the consumer has to use the behaviour purely from a template, for example in a low-code or CMS-driven setup, or when the behaviour needs to render something itself, like a portal or a focus trap around slot content. Headless primitives such as Reka UI are the useful middle ground: they render minimal semantic markup with the accessibility handled, and leave styling to the consumer."

Your notes

Open in Notes

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