frontend / vue / 12_advanced_features.md

<Teleport>, <Suspense>, Async Components, Custom Directives

5 min read source

<Teleport>, <Suspense>, Async Components, Custom Directives

TL;DR

Four advanced built-ins that come up under “have you used X” questions. <Teleport> renders content to a different DOM target (modals, tooltips). <Suspense> lets components declare async setup and parents render fallback while waiting. Async components (defineAsyncComponent) lazy-load components on demand. Custom directives (v-myDirective) implement low-level DOM behavior that doesn’t fit a component (autofocus, click-outside, intersection-observer).

Interview Q&A

Q: <Teleport> — what and why?

A: Renders the slotted content as if it lived at a different DOM location, without changing the component tree. Use for modals, tooltips, popovers, toasts — anything that should escape parent overflow: hidden or stacking contexts.

<template>
  <Teleport to="body">
    <div class="modal" v-if="open">
      <h2>Title</h2>
      <slot />
    </div>
  </Teleport>
</template>

The <div class="modal"> renders directly under <body>, escaping any ancestor with transform/filter/overflow: hidden that would otherwise create a stacking context or clip it. Vue still treats it as part of this component’s lifecycle (events bubble logically, not via DOM).

Q: <Teleport> vs React createPortal?

A: Same idea, different syntax. Both move the rendered DOM to another mount point while keeping React/Vue’s logical tree intact.

// React
return createPortal(<Modal />, document.body);

vs

<Teleport to="body">
  <Modal />
</Teleport>

Q: <Suspense> — what does it solve?

A: Lets a child component declare async setup (async setup() or top-level await in <script setup>); the parent’s <Suspense> renders a fallback while that resolves, and the resolved component when ready.

<!-- AsyncChild.vue -->
<script setup>
const data = await $fetch("/api/data");    // top-level await
</script>
<template>{{ data }}</template>

<!-- Parent -->
<template>
  <Suspense>
    <template #default>
      <AsyncChild />
    </template>
    <template #fallback>
      <Spinner />
    </template>
  </Suspense>
</template>

Suspense in Vue 3 is still labeled experimental — the API may shift. Most teams either avoid it for production-critical paths or wrap it carefully. Nuxt uses it under the hood for useAsyncData.

Q: <Suspense> vs useFetch/useAsyncData?

A:

  • useFetch exposes { pending, data, error } — you handle the loading state in the component’s template (<div v-if="pending">...).
  • <Suspense> lets a child component be async and the parent show a fallback.

In Nuxt, useFetch is usually preferred — explicit, no Suspense ceremony. <Suspense> shines when the fallback is at a layout level and several deeply nested components are async together.

Q: Async components — when use defineAsyncComponent?

A: Lazy-load a component only when it’s first rendered. Same purpose as React’s React.lazy.

import { defineAsyncComponent } from "vue";

const HeavyChart = defineAsyncComponent(() => import("./HeavyChart.vue"));

// or with options
const HeavyChart = defineAsyncComponent({
  loader: () => import("./HeavyChart.vue"),
  loadingComponent: Spinner,
  errorComponent: ErrorView,
  delay: 200,             // ms before showing loadingComponent
  timeout: 10_000,        // ms before showing errorComponent
});

The dynamic import creates a code-split chunk. The chart’s JS is fetched the first time <HeavyChart> mounts.

Use for:

  • Routes’ top-level components (Vue Router supports them directly).
  • Modal contents (don’t ship modal code until needed).
  • Charts, editors, anything heavy used by a fraction of sessions.

Q: Custom directives — when to reach for one?

A: Low-level DOM behavior that doesn’t fit a component:

// directives/focus.ts
export default {
  mounted(el: HTMLElement) {
    el.focus();
  },
};

// register globally
app.directive("focus", focusDirective);

// use
<input v-focus />
// directives/click-outside.ts — more advanced
export default {
  beforeMount(el: HTMLElement, binding: any) {
    const handler = (e: Event) => {
      if (!el.contains(e.target as Node)) binding.value(e);
    };
    (el as any)._clickOutside = handler;
    document.addEventListener("click", handler);
  },
  unmounted(el: HTMLElement) {
    document.removeEventListener("click", (el as any)._clickOutside);
  },
};

<div v-click-outside="onClose">...</div>

Real use cases: v-focus, v-click-outside, v-intersect (intersection observer), v-tooltip, v-resize. Custom directive is the right tool when the behavior is tied to a DOM element and doesn’t need its own template/state.

Component would be wrong because no template; composable would be wrong because no element reference at usage site. Directive fits the shape.

Q: Directive hooks lifecycle.

A:

Hook When
created before element bindings/listeners applied
beforeMount before element inserted into DOM
mounted after inserted
beforeUpdate before component re-renders
updated after component re-renders
beforeUnmount before removed
unmounted after removed

All receive (el, binding, vnode, prevVnode). binding.value is the value passed (v-foo="value"); binding.arg for v-foo:arg; binding.modifiers for v-foo.mod.

Q: <Transition> and <TransitionGroup> — animation primitives.

A: Brief mention since they often come up:

<Transition name="fade">
  <p v-if="show">Hello</p>
</Transition>

<TransitionGroup name="list" tag="ul">
  <li v-for="item in items" :key="item.id">{{ item.text }}</li>
</TransitionGroup>

CSS classes (fade-enter-from, fade-enter-active, fade-leave-to, etc.) are added/removed at lifecycle moments. JS hooks (@before-enter, @enter, etc.) for imperative animations.

For complex animations, @vueuse/motion or motion-v (Framer Motion port).

Gotchas / edge cases

  • <Teleport> target must exist when the component mounts. SSR-rendered teleport targets need <Teleport disabled> until client-side, or use client-only.
  • <Suspense> still experimental — API may change; error boundaries inside are tricky.
  • defineAsyncComponent with <Suspense> parent — automatically integrated; the parent’s fallback shows during load.
  • Custom directives don’t compose the way components do. If two directives both need cleanup on a single element, you risk conflicts unless they use unique property names (el._myDirective pattern shown above).
  • Directives are global by registration (app.directive) — for SFC-local directives, prefix with v and name as vMyName in <script setup>:
    <script setup>
    const vFocus = { mounted: (el) => el.focus() };
    </script>
    <input v-focus />
  • <Teleport> and z-index — moving DOM to <body> helps with stacking, but a CSS reset that scopes z-indexes per-modal still matters.

What a senior is expected to say

  • <Teleport> for content that needs to escape parent stacking contexts or overflow — modals, tooltips, popovers. Same idea as React’s createPortal.”
  • <Suspense> is experimental in Vue 3; I avoid it as a primary loading pattern in production. useFetch/useAsyncData with {pending} is more explicit.”
  • defineAsyncComponent for lazy-loading; pair with route-level code splitting in Vue Router (which uses async components natively).”
  • “Custom directives for behaviors tied to a single DOM element where a component doesn’t fit — v-focus, v-click-outside, v-intersect. Composables when there’s no element to attach to.”
  • <Transition> for one-element animations, <TransitionGroup> for list animations; @vueuse/motion for serious animation needs.”

Cross-references

Further reading