Vue 3 in depth

Rendering and lifecycle

Lifecycle order on server and client, nextTick, Suspense, Teleport, KeepAlive and Transition, async components, template compilation, keys and render functions.

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.

Know

  • Lifecycle order: 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.
  • On the server only 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.
  • Updates are asynchronous. After a mutation the DOM is stale until the scheduler flushes; 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.
  • Async components via 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).
  • Templates compile to optimised render functions: static nodes are hoisted, dynamic bindings get patch flags so the diff checks only what can change, and event handlers are cached. 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.
  • Keys. 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.
  • Template refs. 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.

How it works

Suspense, an async component and an error boundary together:

app/components/Team/Dashboard.vue
<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:

app/components/Team/Dialog.vue
<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:

app/components/Team/Tree.ts
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)
  },
})
Gotcha· onMounted never runs on the server

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).

Gotcha· Index as key

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.

Verify before the interview:

Whether <Suspense> is still marked experimental and whether the suspensible prop and nested-Suspense behaviour changed in Vue 3.6.

docs ↗

Exercise

Exercise
  • Write a component that logs every lifecycle hook, render it inside a Nuxt page, and compare the server log with the browser console. Then wrap it in <KeepAlive> behind a toggle and watch onActivated/onDeactivated replace mount/unmount.
  • Build a list with :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.
  • Teleport a dialog to 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.

Be able to say

Be able to say· What runs on the server and what runs on the client in a component's lifecycle?

"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."

Be able to say· How does Suspense work and where does Nuxt use 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."

Your notes

Open in Notes

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