Skip to the solution
MediumFrontend9 min read

How do <Transition>, <KeepAlive> and <Suspense> compose in Vue 3?

1.1k views

The problem

Question presented to candidate: "Our app animates route changes, caches the dashboard so its filters survive, and some pages load data with an async setup(). Show me how you would nest <Transition>, <KeepAlive> and <Suspense> around <RouterView>, and explain why the order matters."

What a strong answer should cover:

  • The documented order, outside in: <RouterView v-slot> β†’ <Transition> β†’ <KeepAlive> β†’ <Suspense> β†’ <component :is>.
  • Why: <Transition> animates whatever single child it has; <KeepAlive> must sit directly around the thing whose instances it caches; <Suspense> waits for the async component inside it.
  • mode="out-in" on the transition so the old page leaves before the new one enters.
  • After its first resolve, <Suspense> keeps showing the old content while the next page's async setup runs (unless timeout is set), so the fallback is mostly a first-load concern.
  • A cached page coming back resolves immediately and runs onActivated, not a new setup().
  • A wrong order does not throw: it just quietly loses caching or animations.
  • <Suspense> is still marked experimental in Vue 3.5.

Clarifying questions expected:

  • "Which pages need caching, and which need to refetch on every visit?"
  • "Should a slow page show a spinner, or keep the old page until the new one is ready?"

Code / implementation expected: Yes β€” the nested <RouterView> template, and a note on what each layer is responsible for.

  • transition
  • keepalive
  • suspense

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 10 sections

Read the code

VueTransition, KeepAlive and Suspense in the documented order, with async views
<script setup>
import { ref, shallowRef, defineComponent, h, onMounted, onActivated } from "vue";

// Each view has an async setup(): it "loads data" before it can render.
const makeView = (name, ms) =>
  defineComponent({
    name,
    async setup() {
      // Lifecycle hooks must be registered BEFORE the first await.
      onMounted(() => console.log(`  ${name}: mounted`));
      onActivated(() => console.log(`  ${name}: activated`));
      const clicks = ref(0);
      console.log(`  ${name}: setup started`);
      await new Promise((r) => setTimeout(r, ms));
      return () => h("button", { class: "view", onClick: () => clicks.value++ }, `${name} clicks ${clicks.value}`);
    },
  });

const views = { Home: makeView("Home", 50), Reports: makeView("Reports", 80) };
const current = shallowRef(views.Home);
</script>

<template>
  <nav>
    <button v-for="(v, name) in views" :key="name" class="nav" @click="current = v">{{ name }}</button>
  </nav>
  <!-- Order matters: Transition > KeepAlive > Suspense > the component -->
  <Transition mode="out-in" name="fade" :duration="40">
    <KeepAlive>
      <Suspense @pending="console.log('  suspense: pending')" @fallback="console.log('  suspense: fallback shown')" @resolve="console.log('  suspense: resolved')">
        <component :is="current" />
        <template #fallback>
          <p class="loading">Loading...</p>
        </template>
      </Suspense>
    </KeepAlive>
  </Transition>
</template>

<style>
.fade-enter-active, .fade-leave-active { transition: opacity 0.04s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
The same stack around RouterView with vue-router 5, run in jsdom
// RouterView + Transition + KeepAlive + Suspense, run in jsdom with vue-router 5.4.0.
import { JSDOM } from "jsdom";
const dom = new JSDOM('<div id="app"></div>', { pretendToBeVisual: true, url: "http://localhost/" });
for (const k of ["window", "document", "Node", "Element", "HTMLElement", "SVGElement", "Text", "Comment", "requestAnimationFrame", "getComputedStyle", "history", "location"]) globalThis[k] = dom.window[k];
const { createApp, defineComponent, h, ref } = await import("vue");
const { createRouter, createMemoryHistory } = await import("vue-router");

const page = (name, ms) => defineComponent({
  name,
  async setup() {
    const n = ref(0);
    await new Promise((r) => setTimeout(r, ms)); // pretend to fetch
    return () => h("button", { class: "page", onClick: () => n.value++ }, `${name} ${n.value}`);
  },
});
const router = createRouter({
  history: createMemoryHistory(),
  routes: [{ path: "/", component: page("Home", 30) }, { path: "/reports", component: page("Reports", 30) }],
});

const App = {
  template: `
    <RouterView v-slot="{ Component }">
      <template v-if="Component">
        <Transition mode="out-in" :duration="20">
          <KeepAlive>
            <Suspense>
              <component :is="Component" />
              <template #fallback><p class="page">Loading...</p></template>
            </Suspense>
          </KeepAlive>
        </Transition>
      </template>
    </RouterView>`,
};
const app = createApp(App).use(router);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const screen = () => document.querySelector(".page")?.textContent;
await router.push("/"); app.mount("#app");
console.log("first paint :", screen());
await wait(200); console.log("+200ms      :", screen());
document.querySelector(".page").click(); document.querySelector(".page").click(); await wait(20); console.log("2 clicks    :", screen());
await router.push("/reports"); await wait(200); console.log("/reports    :", screen());
await router.push("/"); await wait(200); console.log("back to /   :", screen());

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 21 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextMedium

How do Transition and TransitionGroup work?

Continue