<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:
useFetchexposes{ 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 useclient-only.<Suspense>still experimental — API may change; error boundaries inside are tricky.defineAsyncComponentwith<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._myDirectivepattern shown above). - Directives are global by registration (
app.directive) — for SFC-local directives, prefix withvand name asvMyNamein<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’screatePortal.” - “
<Suspense>is experimental in Vue 3; I avoid it as a primary loading pattern in production.useFetch/useAsyncDatawith{pending}is more explicit.” - “
defineAsyncComponentfor 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/motionfor serious animation needs.”
Cross-references
- Lifecycle hooks (relevant to directives): 06_lifecycle_hooks.md
- Performance — async components for code splitting: 13_performance.md
- Lightbox/modal a11y (same pattern as
<Teleport>): ../14_frontend_system_design/03_image_gallery.md
Further reading
- Vue docs — Teleport: https://vuejs.org/guide/built-ins/teleport.html
- Vue docs — Suspense: https://vuejs.org/guide/built-ins/suspense.html
- Vue docs — Async Components: https://vuejs.org/guide/components/async.html
- Vue docs — Custom Directives: https://vuejs.org/guide/reusability/custom-directives.html
- Vue docs — Transitions: https://vuejs.org/guide/built-ins/transition.html