Frontend / Vue / 12_advanced_features.md

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

Updated 5 interview angles 5 min read source
On this page12 — what and why? vs React createPortal? — what does it solve? vs useFetch/useAsyncData?
  • Async components — when use defineAsyncComponent?
  • Custom directives — when to reach for one?
  • Directive hooks lifecycle.
  • and — animation primitives.
  • Gotchas
  • Related
  • Further reading
  • Interview angle
  • <Teleport>, <Suspense>, Async Components, Custom Directives

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

    <Teleport> — what and why?

    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.

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

    <Teleport> vs React createPortal?

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

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

    vs

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

    <Suspense> — what does it solve?

    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.

    vue
    <!-- 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.

    <Suspense> vs useFetch/useAsyncData?

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

    Async components — when use defineAsyncComponent?

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

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

    Custom directives — when to reach for one?

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

    ts
    // directives/focus.ts
    export default {
      mounted(el: HTMLElement) {
        el.focus();
      },
    };
    
    // register globally
    app.directive("focus", focusDirective);
    
    // use
    <input v-focus />
    ts
    // 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.

    Directive hooks lifecycle.

    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.

    <Transition> and <TransitionGroup> — animation primitives.

    Brief mention since they often come up:

    vue
    <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

    • <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>:
      vue
      <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.

    Further reading

    Interview angle 5

    • “When do you need <Teleport>?” - when content must escape its parent’s stacking or overflow context: modals, tooltips, popovers. Rendering them in place means an ancestor’s overflow: hidden or transform clips or traps them. It is the same tool as React’s createPortal.
    • “Would you build loading states on Vue’s <Suspense>?” - not in production. It is still marked experimental and its API has moved. Explicit pending state from useFetch or useAsyncData is more predictable and reads more clearly at the call site.
    • “How do you lazy-load a component?” - defineAsyncComponent, and at the route level a () => import() in the router, which Vue Router handles natively. Route-level splitting is where the bundle win actually is; component-level is for genuinely heavy widgets.
    • “Directive or composable?” - a directive when the behaviour is attached to one specific DOM element - v-focus, v-click-outside, v-intersect. A composable when there is no element to bind to and you are managing state or a subscription.
    • “How do you animate?” - <Transition> for a single element entering or leaving, <TransitionGroup> for lists, where it also handles the move animation when items reorder. For anything beyond that a motion library is less work than hand-written CSS.