VueからNuxtへの移行ガイド 第2弾【データフェッチ編】

本連載の第1弾では、VueからNuxtへの移行における「ルーティング」の根本的な違いと、その移行戦略について解説しました。規約ベースのルーティングシステムに適応することは、Nuxtアプリケーションの骨格を構築する上で不可欠なステップです。
しかし、アプリケーションが真に機能するためには、外部APIなどから動的にデータを取得し、それを表示する必要があります。純粋なVue.jsアプリケーションでは、このデータ取得は通常クライアントサイドで行われます。このアプローチはシンプルですが、初期表示速度の遅延やSEOにおける不利といった課題を抱えています。
本稿は、連載の第2弾として、移行プロセスにおけるもう一つの重要なテーマ「データフェッチ」に焦点を当てます。 Nuxtが提供するユニバーサルなデータ取得メカニズムが、これらの課題をいかに解決するのか、そして既存のVueアプリケーションからその恩恵を受けるための具体的な移行プラクティスを詳解します。

目次を読み込み中…

はじめに

本記事は下記第1弾をはじめとするVue to Nuxtの連載シリーズです。
ぜひ順に読んでみてください。

Vueでのデータフェッチ

vue-routerを用いた一般的なVueアプリケーションでは、データ取得はコンポーネントがマウントされた後、クライアントのブラウザ上で行われます。 onMountedライフサイクルフック内で非同期にAPIを呼び出し、取得したデータをリアクティブな変数に格納するのが典型的なパターンです。

このアプローチでは、ローディング状態やエラー状態の管理を開発者が手動で行う必要があります。

vue
<script setup>
const route = useRoute()
const post = ref(null)
const loading = ref(true)
const error = ref(null)

onMounted(async () => {
  try {
    const response = await fetch(`https://api.example.com/posts/${route.params.id}`)
    if (!response.ok) {
      throw new Error('記事の取得に失敗しました。')
    }
    post.value = await response.json()
  } catch (e) {
    error.value = e
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <div v-if="loading">読み込み中...</div>
  <div v-else-if="error" class="error">{{ error.message }}</div>
  <article v-else-if="post">
    <h1>{{ post.title }}</h1>
  </article>
</template>

このコードは機能しますが、いくつかのボイラープレートをコンポーネントごとに記述する必要があります。

NuxtのデータフェッチAPI

Nuxt 3 には、SSR/CSR を横断してデータ取得できる useFetch と useAsyncData が用意されています。 <script setup> 直下に await を記述すると、SSRパイプラインは該当Promiseの解決を待機し、結果を初期HTMLと同梱のNuxt payloadにシリアライズして返却します。 これにより初期描画と検索クローラ向けの可視性が向上します。

useFetch で最小・高速な HTTP 取得

HTTP GET を主とする取得に最適(内部実装は $fetch)。状態管理(data/pending/error)と再取得(refresh)を一度に扱えます。

例1: 基本(型、安全な初期値、整形)

vue
<script setup lang="ts">
type Post = { id: number; title: string; body: string }
const route = useRoute()

const { data: post, pending, error, refresh } = await useFetch<Post>(
  () => `https://api.example.com/posts/${route.params.id}`,
  {
    // 初期形で null チェックを回避
    default: () => ({ id: 0, title: '', body: '' }),
    // 必要に応じてキーを明示(キャッシュ制御)
    key: () => `post:${route.params.id}`,
    // 取得後に形を整える
    transform: (raw) => ({ ...raw, title: raw.title?.trim() }),
    // 追加のクエリやヘッダは $fetch オプションとして渡せる
    query: { expand: 'author' },
    headers: { Accept: 'application/json' }
  }
)
</script>

<template>
  <button @click="refresh">更新</button>
  <div v-if="pending">読み込み中...</div>
  <div v-else-if="error" class="error">記事の取得に失敗しました。</div>
  <article v-else>
    <h1>{{ post.title }}</h1>
    <p>{{ post.body }}</p>
  </article>
  
</template>

例2: ルート変更に追従して自動再取得(関数形式の URL)

vue
const route = useRoute()
const { data, pending } = await useFetch(() =>
  `/api/posts/${route.params.id}`
)
// 依存(route.params.id)が変わると自動で再フェッチされる

例3: クライアントのみ/非ブロッキング初期表示

vue
// SSRでは取得せず、クライアントでのみ実行
useFetch('/api/me', { server: false })

// SSRをブロックせず、初回はクライアントで取得(ローディング表示を優先)
useFetch('/api/feed', { lazy: true })

実務上の観点としては、URL を関数として渡しリアクティブな依存を持たせると、依存の変化に応じて自動的に再取得が走ります。 追加の依存がある場合は watch オプションで明示できます。 また、同一キーの結果はキャッシュされるため、必要なタイミングで refresh() を呼び出せば再取得を強制できます。 返却値の一部だけを使いたいときは pick: ['title', 'id'] を指定すると、転送量と再活性コストを抑えられます。

useAsyncData で SDK/複数リクエストを柔軟に扱う

useAsyncData はトランスポート非依存のプリミティブで、HTTP 以外の SDK(例: GraphQL/CMS クライアント)や複数の非同期処理を合成したい場面に適しています。 第一引数でキャッシュキーを与え、第二引数のハンドラに任意の非同期処理を記述します。

例: CMS SDK とコメント API を合成し、型と依存を明示する

vue
<script setup lang="ts">
import { cms } from '~/lib/cms' // 例: Headless CMS SDK
type Article = { id: string; title: string; body: string }
type Comment = { id: string; body: string }

const route = useRoute()
const key = computed(() => `article:${route.params.id}`)

const { data: articleData, pending, error, refresh } = await useAsyncData(
  key,
  async () => {
    const [article, comments] = await Promise.all<[
      Article,
      Comment[]
    ]>([
      cms.getArticle(String(route.params.id)),
      $fetch(`/api/articles/${route.params.id}/comments`)
    ])
    return { article, comments }
  },
  {
    default: () => ({ article: null as Article | null, comments: [] as Comment[] }),
    // 依存が変化したら自動再実行(URL関数と同等の挙動)
    watch: [() => route.params.id],
    // クライアント限定で取得したい場合は server:false
    // server: false,
    // 必要なら返却形を整形
    transform: (d) => ({ ...d, article: d.article && { ...d.article, title: d.article.title.trim() } }),
    // 一部のみを追跡・直列化してペイロード削減
    pick: ['article', 'comments']
  }
)
</script>

この形にしておくと、ルートパラメータが変わった際に再取得が自動で走り、同一キーに対しては内部キャッシュが効くため無駄なリクエストも生じません。 ミューテーション後に最新状態へ更新したい場合は、返り値の refresh() で当該クエリのみを再取得するか、refreshNuxtData(key.value) で同じキーを共有する別コンポーネントも含めて一括更新できます。 SSR をブロックしたくない場合は server: false や lazy: true を併用し、初回はクライアント側でローディング、描画の順にするのが実務上扱いやすい場面もあります。

補足として、useFetch(url) は実質的に useAsyncData(url, () => $fetch(url)) のシンタックスシュガーです。さらに、同一キーの結果はキャッシュされナビゲーション間でも再利用されるため、必要に応じて refresh() を実行すれば最新状態へ更新できます。

移行戦略ガイド

VueからNuxtへデータフェッチのロジックを移行する手順は以下の通りです。 既存の Vue アプリを破壊的変更なく段階的に Nuxt へ移すための実務手順を示します。

1. 既存フェッチの棚卸し

まず、onMounted/created、カスタムフック、メソッド内などで行っている fetch/axios 呼び出しを洗い出します。 画面初期表示を左右する取得(詳細ページの本体データなど)と、後追い取得でもよい補助データを分けておくと、この後の SSR/CSR の切り分け判断が容易になります。

2. API 置換の原則(useFetch/useAsyncData)

単純な GET リクエストは useFetch へ置換し、Vue 側で手動管理していた data/loading/error は data/pending/error に読み替えます。 複数リクエストの合成や SDK/CMS クライアントを扱う場合は useAsyncData を選び、ハンドラ関数に非同期処理をまとめます。 いずれもキャッシュキー(URL か第一引数のキー)を安定させることが、無駄な再取得を避ける基本方針です。

移行前 (Vue)

vue
const user = ref(null)
onMounted(async () => {
  const res = await fetch('/api/user')
  const data = await res.json()
  user.value = data.profile // ネストしたオブジェクトから取得
})

移行後 (Nuxt with useFetch)

vue
const { data: user } = await useFetch('/api/user', {
  transform: (data) => data.profile // transformでデータを整形
})

3. 状態共有(Pinia)との連携

画面横断で再利用するデータは、ストアのアクションにフェッチ処理を寄せて集約します。 サーバーサイドでの初期取得とクライアント遷移時の再利用が両立し、更新時は refresh() や refreshNuxtData(key) で一貫して最新化できます。

vue
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  const profile = ref(null)

  function setProfile(newProfile) {
    profile.value = newProfile
  }

  async function fetchProfile() {
    // ストア内ではuseFetch/useAsyncDataを直接使う
    const { data } = await useFetch('/api/profile')
    if (data.value) {
      setProfile(data.value)
    }
  }

  return { profile, fetchProfile }
})

コンポーネント側では、このアクションを呼び出すだけです。初期描画で必要な場合は <script setup> 直下で await し、遷移後に非同期でよい場合は server:false や lazy:true を組み合わせて体感速度を優先します。

vue
<script setup>
import { useUserStore } from '~/stores/user'
const userStore = useUserStore()

// サーバーサイドでデータを取得し、ストアにセットする
await userStore.fetchProfile()
</script>

まとめ

Vue から Nuxt への移行で最も効果が現れるのがデータ取得の設計です。 <script setup> 直下での await により SSR が非同期処理を待機し、初期 HTML に結果を同梱できるため、初期描画と検索性が着実に向上します。 単純な HTTP 取得は useFetch に置き換え、複数の処理や SDK を伴う場合は useAsyncData に集約する、という役割分担を徹底すれば、ボイラープレートは減り、キャッシュキーを軸とした再取得や更新フローも一貫します。 移行時は、初期表示に必須のデータと後追いでもよいデータを仕分け、前者は SSR で待機、後者は server:false/lazy:true で体感速度を優先するのが実務的です。 共有が必要なデータは Pinia に寄せ、refresh() や refreshNuxtData(key) を使って一貫して最新化します。 次回は、ページ間での再利用性を高める「状態管理」戦略を取り上げ、ストア設計とデータフェッチの責務分離を深掘りします。

この記事は役に立ちましたか?

もし参考になりましたら、下記のボタンで教えてください。

関連記事

コメント

この記事へのコメントはありません。

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)