Vue3で非同期データの取得中にローディング表示を出す処理は、v-ifref フラグの管理が煩雑になりがちです。<Suspense> は、この非同期描画の制御を宣言的なテンプレート構文で解決する Vue3 の組み込みコンポーネントです。

Suspenseの概要と現在のステータス

<Suspense> は、子コンポーネントツリーに含まれる非同期依存関係(async setup や非同期コンポーネント)がすべて解決されるまで、代替コンテンツ(フォールバック)を自動的に表示します。React にも同名の機能がありますが、Vue の Suspense は Composition API の async setup()<script setup> のトップレベル await と直接連携する点が特徴です。

ステータス: 2026年2月時点で、Vue 公式ドキュメントは Suspense を 実験的機能(Experimental) と位置づけています。API が今後変更される可能性がある点は、本番導入の際に考慮が必要です(出典: Vue.js公式ドキュメント)。

Suspenseが解決する3つの課題

1. v-if フラグ管理の排除

Suspense を使わない場合、非同期データの読み込み中に表示を切り替えるには、以下のように isLoading フラグを自分で管理する必要があります。

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const isLoading = ref(true)
const data = ref<string[]>([])

onMounted(async () => {
  data.value = await fetchItems()
  isLoading.value = false
})
</script>

<template>
  <div v-if="isLoading">読み込み中...</div>
  <ul v-else>
    <li v-for="item in data" :key="item">{{ item }}</li>
  </ul>
</template>

コンポーネントが増えるたびに isLoading を追加する必要があり、管理コストが増大します。

2. ポップコーン現象の防止

画面内に複数の非同期コンポーネントがある場合、それぞれが異なるタイミングで描画されると、コンテンツが順番にポコポコ出現するポップコーン現象が発生します。Suspense はスロット内の全非同期依存関係が解決するまでフォールバックを維持するため、画面全体が一度に切り替わります。

3. ローディング状態の一元管理

Suspense を使えば、ローディングの表示・非表示ロジックは親のテンプレートに集約されます。子コンポーネントは非同期データの取得だけに集中でき、責務が明確に分離されます。

基本構文: #default と #fallback スロット

Suspense は2つの名前付きスロットで動作します。

<template>
  <Suspense>
    <template #default>
      <AsyncUserList />
    </template>
    <template #fallback>
      <p>ユーザー一覧を取得しています...</p>
    </template>
  </Suspense>
</template>
スロット役割
#default非同期依存関係を含むメインコンテンツ
#fallback非同期処理の完了を待つ間に表示される代替コンテンツ

制約: 各スロットの直下に配置できるルートノードは 1つだけ です。複数のコンポーネントを表示したい場合は、ラッパー要素で囲みます。

<!-- NG: 直下に複数のルートノード -->
<template #default>
  <UserProfile />
  <UserPosts />
</template>

<!-- OK: ラッパーで囲む -->
<template #default>
  <div>
    <UserProfile />
    <UserPosts />
  </div>
</template>

非同期依存関係の2つのパターン

Suspense が認識する非同期依存関係は2種類あります。

パターン1: async setup / トップレベル await

<script setup> 内でトップレベルの await を使うと、そのコンポーネントは自動的に非同期コンポーネントとして扱われます。

<!-- AsyncUserList.vue -->
<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

const res = await fetch('https://jsonplaceholder.typicode.com/users')
const users: User[] = await res.json()
</script>

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      {{ user.name }} ({{ user.email }})
    </li>
  </ul>
</template>

Options API の場合は async setup() を返す形で記述します。

import { defineComponent } from 'vue'

export default defineComponent({
  async setup() {
    const res = await fetch('https://jsonplaceholder.typicode.com/users')
    const users = await res.json()
    return { users }
  }
})

パターン2: defineAsyncComponent による非同期コンポーネント

defineAsyncComponent で定義したコンポーネントも、Suspense のツリー内に配置するとフォールバックの対象になります。

import { defineAsyncComponent } from 'vue'

const HeavyChart = defineAsyncComponent(
  () => import('./components/HeavyChart.vue')
)

defineAsyncComponent 自体にも loadingComponentdelay のオプションがありますが、Suspense と併用する場合は Suspense 側のフォールバックが優先 されます。個別のローディング制御が不要なら、Suspense に任せたほうがテンプレートがシンプルになります。

Suspense のイベントを活用する

Suspense は状態遷移に応じて3種類のイベントを発行します。

イベント発火タイミング
@pending非同期依存関係が未解決の状態(pending)に入った時
@resolveすべての依存関係が解決され、#default の内容が表示された時
@fallback#fallback の内容が表示された時
<script setup lang="ts">
function onPending() {
  console.log('非同期処理を待機中...')
}

function onResolve() {
  console.log('描画完了')
}

function onFallback() {
  console.log('フォールバック表示')
}
</script>

<template>
  <Suspense
    @pending="onPending"
    @resolve="onResolve"
    @fallback="onFallback"
  >
    <AsyncDashboard />
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>

timeout プロパティで表示切り替えを制御する

timeout プロパティを指定すると、新しい非同期コンテンツのレンダリングに指定ミリ秒以上かかった場合のみフォールバックに切り替わります。

<!-- 200ms以上かかった場合のみローディング表示 -->
<Suspense timeout="200">
  <AsyncContent />
  <template #fallback>
    <LoadingSpinner />
  </template>
</Suspense>

timeout="0" を指定すると、即座にフォールバックが表示されます。データ取得が高速なケースではローディング表示のチラつきを防ぎ、遅い場合だけフォールバックを見せたいシーンで有用です。

エラーハンドリング: onErrorCaptured フックの実装

Suspense 自体にはエラー処理機構が組み込まれていません。非同期処理中に発生したエラーは、親コンポーネントで onErrorCaptured フックを使って捕捉します。

<!-- ParentWithErrorHandling.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
import AsyncUserList from './AsyncUserList.vue'

const error = ref<Error | null>(null)

onErrorCaptured((err) => {
  error.value = err as Error
  return false // エラーの伝播を止める
})
</script>

<template>
  <div v-if="error" class="error-message">
    エラーが発生しました: {{ error.message }}
  </div>
  <Suspense v-else>
    <AsyncUserList />
    <template #fallback>
      <p>読み込み中...</p>
    </template>
  </Suspense>
</template>

onErrorCaptured のコールバックで return false を返すと、エラーの上位への伝播を停止できます。エラー発生時にフォールバック UI を表示し、リトライボタンを用意するパターンが実用的です。

<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
import AsyncContent from './AsyncContent.vue'

const error = ref<Error | null>(null)
const retryKey = ref(0)

onErrorCaptured((err) => {
  error.value = err as Error
  return false
})

function retry() {
  error.value = null
  retryKey.value++
}
</script>

<template>
  <div v-if="error" class="error-container">
    <p>{{ error.message }}</p>
    <button @click="retry">再読み込み</button>
  </div>
  <Suspense v-else :key="retryKey">
    <AsyncContent />
    <template #fallback>
      <p>読み込み中...</p>
    </template>
  </Suspense>
</template>

:key を変更することで Suspense ごと再マウントされ、非同期処理が再実行されます。

Vue Router との組み合わせ

SPA のページ遷移で各ページコンポーネントに非同期 setup がある場合、<RouterView><Suspense> を組み合わせるとページ遷移時のローディング表示を一元管理できます。

<!-- App.vue -->
<template>
  <RouterView v-slot="{ Component }">
    <Suspense>
      <component :is="Component" />
      <template #fallback>
        <div class="page-loading">ページを読み込み中...</div>
      </template>
    </Suspense>
  </RouterView>
</template>

RouterView 内で fallback が表示されない問題の対処

<RouterView> 内に <Suspense> を配置した場合、Vue がコンポーネントの切り替わりを内部的にパッチとして処理するため、fallback コンテンツが表示されないケースがあります。これは timeout="0" を明示的に指定することで解決できます。

<RouterView v-slot="{ Component }">
  <Suspense timeout="0">
    <component :is="Component" />
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</RouterView>

Transition・KeepAlive との組み合わせ

Suspense を <Transition><KeepAlive> と併用する場合、ネストの順番が重要です。外側から順に RouterViewTransitionKeepAliveSuspense の順で配置します。

<RouterView v-slot="{ Component }">
  <Transition mode="out-in">
    <KeepAlive>
      <Suspense>
        <component :is="Component" />
        <template #fallback>
          <LoadingSpinner />
        </template>
      </Suspense>
    </KeepAlive>
  </Transition>
</RouterView>

この順番を誤ると、トランジションアニメーションが機能しなかったり、KeepAlive のキャッシュが効かなかったりする原因になります。

ネストした Suspense と suspensible プロパティ

外側と内側の両方に Suspense がある場合、内側の Suspense は独立した境界として振る舞います。内側の非同期処理を外側の Suspense に委任したい場合は、suspensible プロパティを使います。

<Suspense>
  <template #default>
    <OuterAsyncComponent>
      <!-- suspensible を付けると外側の Suspense に制御を委譲 -->
      <Suspense suspensible>
        <InnerAsyncComponent />
      </Suspense>
    </OuterAsyncComponent>
  </template>
  <template #fallback>
    <p>全体を読み込み中...</p>
  </template>
</Suspense>

suspensible を付けない場合、内側の Suspense は自分の #fallback を表示し、外側の Suspense とは独立して解決します。suspensible を付けると、内側の非同期依存関係も外側の pending 状態に含まれるため、全体が一度に切り替わります。

従来パターンとの比較

観点v-if + isLoading パターンSuspense パターン
ローディング状態の管理各コンポーネントで ref(true) を個別管理自動(Suspense が検知)
複数非同期の協調全フラグを AND 条件で結合する必要ありスロット内の全依存関係を自動で待機
テンプレートの記述量v-if / v-else を各所に記述Suspense で一括ラップ
エラーハンドリングtry-catch を各所に記述onErrorCaptured で親に集約
ページ遷移との統合RouterView とは別途実装RouterView v-slot で統合可能
子コンポーネントの責務ローディング表示ロジックも含むデータ取得のみに集中

Nuxt 3 での Suspense 活用

Nuxt 3 は内部的に Suspense を利用しています。useAsyncDatauseFetch といったコンポーザブルは、SSR 時にサーバーサイドでデータを取得し、クライアントサイドではキャッシュされたデータを利用する仕組みです。

<!-- pages/users.vue (Nuxt 3) -->
<script setup lang="ts">
const { data: users, status } = await useFetch('/api/users')
</script>

<template>
  <div>
    <ul>
      <li v-for="user in users" :key="user.id">{{ user.name }}</li>
    </ul>
  </div>
</template>

Nuxt 3 のページコンポーネントは自動的に Suspense でラップされるため、<NuxtPage> を使用するだけでローディング制御が有効になります。明示的に Suspense を記述する必要はありません。

本番導入で注意すべきポイント

Experimental ステータスへの対応

Suspense は実験的機能のため、マイナーバージョンアップで API が変更される可能性があります。本番で利用する場合は、Vue のバージョンを固定(package.json でキャレット ^ ではなく固定バージョンを指定)し、アップデート時に公式の変更履歴を必ず確認してください。

各スロットの単一ルートノード制約

#default#fallback それぞれの直下に配置できるルートノードは1つだけです。複数のコンポーネントを並べる場合は <div> などのラッパーが必要になります。この制約を忘れると、意図しない描画結果や警告メッセージが発生します。

SSR との関係

Vue3 の SSR では、サーバーサイドで非同期依存関係が解決されるまで HTML の送出を待機します。Suspense を使った場合も同様で、サーバーサイドでは #default の内容が解決された状態で HTML が出力されます。そのため、SSR 環境ではフォールバックコンテンツはサーバーからのレスポンスには含まれません。

console に表示される警告への対処

<Suspense> 内のコンポーネントが async setup を持たない場合や、<RouterView> と併用した際に以下のような警告が表示されることがあります。

[Vue warn]: Component <Anonymous>: setup function returned a promise, but no <Suspense> boundary was found in the parent component tree.

この警告は、async setup を持つコンポーネントが Suspense で囲まれていない場合に出力されます。非同期処理を含むコンポーネントは必ず Suspense の子孫として配置してください。

まとめ

Vue3 の <Suspense> は、非同期コンポーネントの描画制御をテンプレートの宣言的構文で簡潔に記述できる組み込みコンポーネントです。v-ifisLoading フラグの手動管理から解放され、ポップコーン現象の防止・エラー処理の集約・Vue Router との統合が可能になります。

現時点では実験的機能というステータスですが、Nuxt 3 が内部的に採用していることからも、非同期描画制御のスタンダードとなる方向性は明確です。本番導入の際は Vue のバージョンを固定しつつ、公式ドキュメントの更新を追いかけることで、安全に活用できます。