The built-in components and the lifecycle are where "Vue knowledge" meets "SSR knowledge". Nuxt wraps every page in <Suspense>, teleports to body are a classic hydration trap, and onMounted not running on the server explains half of all "works in dev, breaks in production" bugs. Interviewers ask about these because the answers show whether you have shipped a server-rendered app.
setup → onBeforeMount → onMounted → (onBeforeUpdate → onUpdated)* → onBeforeUnmount → onUnmounted. Children mount before the parent's onMounted fires. onErrorCaptured catches descendant errors, onActivated/onDeactivated pair with KeepAlive, onServerPrefetch runs only on the server.setup and onServerPrefetch run. No mount, no update, no unmount: anything registered with setInterval or addEventListener in setup on the server never gets cleaned up (a leak) or never gets registered (a no-op). DOM access belongs in onMounted.await nextTick() before measuring or focusing. Tests do the same.<Suspense> waits for async setup (top-level await) and async components in its default slot, showing #fallback meanwhile; it emits pending/resolve/fallback. The Vue docs still mark it experimental, but Nuxt builds page rendering and useAsyncData on it, so you have used it in every Nuxt app. Errors inside are caught with onErrorCaptured on an ancestor.<Teleport to="body"> moves the DOM while keeping the component in its logical tree, so provide/inject and props still work. defer (3.5) waits for a target rendered later in the same tick. On the server, teleported content is rendered separately and Nuxt stitches body teleports into the HTML; a target that differs between server and client is a hydration mismatch.<KeepAlive include exclude max> caches component instances instead of destroying them; onActivated/onDeactivated replace mount/unmount for cached components. It trades memory for state preservation; max bounds it. In Nuxt: definePageMeta({ keepalive: true }) or <NuxtPage keepalive>.<Transition> and <TransitionGroup> apply enter/leave classes (v-enter-from, v-enter-active, v-enter-to) or JavaScript hooks; mode="out-in" avoids two elements overlapping. Respect prefers-reduced-motion.defineAsyncComponent({ loader, loadingComponent, errorComponent, delay, timeout }) code-split at the component boundary. Nuxt's Lazy prefix does this automatically and adds the 3.5 hydration strategies (hydrate-on-visible, hydrate-on-idle, hydrate-on-interaction, hydrate-never).v-memo skips a subtree unless its dependencies changed; v-once renders once. Hand-written render functions with h() or JSX lose those hints, so use them only for genuinely dynamic trees.key identifies an element across renders. Using the array index for reorderable or filterable lists breaks state and transitions; changing a key deliberately forces a remount, which is the cleanest way to reset a component.useTemplateRef('name') (3.5) replaces the "ref named like the variable" convention. Refs inside v-for give an array whose order is not guaranteed. Function refs exist for dynamic cases.Suspense, an async component and an error boundary together:
<script setup lang="ts">
const HeavyChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
delay: 200, // do not flash the loading state for fast loads
timeout: 10_000,
})
const failed = ref<Error | null>(null)
onErrorCaptured((err) => {
failed.value = err as Error
return false // stop propagation; we rendered a fallback
})
</script>
<template>
<div v-if="failed" role="alert">Could not load the dashboard: {{ failed.message }}</div>
<Suspense v-else>
<template #default>
<HeavyChart />
</template>
<template #fallback>
<USkeleton class="h-64 w-full" />
</template>
</Suspense>
</template>
Measuring after an update, and focusing a teleported dialog:
<script setup lang="ts">
const open = defineModel<boolean>({ default: false })
const panel = useTemplateRef<HTMLDivElement>('panel')
watch(open, async (isOpen) => {
if (!isOpen) return
await nextTick() // the Teleport content exists only after the flush
panel.value?.querySelector<HTMLElement>('[autofocus], button')?.focus()
})
</script>
<template>
<Teleport to="body">
<div v-if="open" ref="panel" role="dialog" aria-modal="true" class="dialog">
<slot />
</div>
</Teleport>
</template>
A render function where a template would be awkward: a component that renders a tree of arbitrary depth:
import { h, type PropType } from 'vue'
interface Node { label: string, children?: Node[] }
export default defineComponent({
name: 'TeamTree',
props: { nodes: { type: Array as PropType<Node[]>, required: true } },
setup(props) {
const render = (nodes: Node[]): ReturnType<typeof h> =>
h('ul', nodes.map(node => h('li', { key: node.label }, [
node.label,
node.children?.length ? render(node.children) : null,
])))
return () => render(props.nodes)
},
})
Code that reads window.innerWidth in setup throws on the server; code that does it in onMounted works but renders a different first frame than the server HTML, which is a hydration mismatch if it changes the markup. Either accept the client-only nature (render a placeholder the server also renders) or derive the value from something available on both sides (a cookie, a useState seeded on the server).
v-for="(item, i) in items" :key="i" means "the third row is always the third row". Delete the second item and Vue patches row three's content into row two while keeping row two's local state, input values and transitions. Use a stable id; use the index only for static lists that never reorder or filter.
Whether <Suspense> is still marked experimental and whether the suspensible prop and nested-Suspense behaviour changed in Vue 3.6.
<KeepAlive> behind a toggle and watch onActivated/onDeactivated replace mount/unmount.:key="index", give each row an <input>, type into row two, delete row one, and watch the text move. Fix it with a stable key.body, open it, and confirm in the server HTML where the markup lands. Then teleport to a #modals div that only exists on the client and observe the hydration warning."On the server Vue runs setup and onServerPrefetch, renders once to a string, and throws the instance away: no mount, update or unmount hooks ever fire. On the client the whole lifecycle runs, but the first render is a hydration pass that must produce the same tree as the server HTML. So DOM access, timers and event listeners go in onMounted, anything that changes markup based on client-only information has to either render a placeholder the server also renders or be derived from data available on both sides, and a setInterval started in setup is a server-side leak because nothing ever clears it."
"Suspense renders a fallback while anything in its default slot is pending: a component whose setup awaits, or an async component still loading. When every pending dependency resolves it swaps in the real tree in one go, so the user never sees half-loaded content. Nuxt wraps <NuxtPage> in Suspense, which is why top-level await useAsyncData() in a page just works and why the page transition waits for data. Errors inside are caught with onErrorCaptured or Nuxt's <NuxtErrorBoundary>. It is still labelled experimental in the Vue docs, but it has been the backbone of Nuxt rendering since Nuxt 3."
Nothing yet. Notes filed here show up on this page and the section overview.
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.
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.