💻

Nuxt 3・Nuxt 4 の useFetch() を完全に理解する(したい)

2024/07/15に公開

以前(まだ Nuxt 3 がベータリリースだった頃)に『Nuxt 3 の useFetch() と useAsyncData() の使い方』という記事を書きました。
その後、より多機能になり、またベストプラクティス的な使い方が分かってきたこともあり、改めて useFetch() についてまとめてみたいと思います。

はじめに

ここ数年、一貫して Nuxt 3 を使用している僕ですが、主要なコンポーザブルである useFetch については当初の理解が違っていたものもありました。
少しずつ修正しながら、都度コードを書き換えてきたのですが、その過程で得られた知見を共有したいと思います。

なお、記事内で Nuxt と書いた場合は Nuxt 3 および Nuxt 4 を指します。
(現時点では Nuxt 4 はリリースされていませんが、発表されている内容から Nuxt 3 との違いを考慮しつつ記述しています)

また、この記事は Nuxt の扱いに慣れていない方も理解してもらえるような記述を心がけていますが、中級者向けの内容も含まれています。
Nuxt を利用している(もしくは興味がある)方は、ひろく参考にしていただければと思います。

公式ページ

https://nuxt.com/docs/getting-started/data-fetching

https://nuxt.com/docs/api/composables/use-fetch

useFetch と $fetch

Nuxt では API 等のデータ取得を行う場合に、 useFetch$fetch の2つの方法があります。
以前の僕自身がそうでしたが、ある程度経験があっても両者の使い分けが充分ではない場合があります。

$fetch は JavaScript 標準の fetch API (や Axios などのライブラリ)の代わりに使用する fetch API です。
リクエスト時のパラメーター等をよしなに処理してくれたり、取得したレスポンスを(JSON等で)パースしてくれたりする便利な機能を活用できます。
(Nuxt 以外で使いたいときも unjs/ofetch により利用可能です)

一方、useFetch は、おもに次のようなシーンで利用することを考慮されたコンポーザブルです。

  • コンポーネント内の描画に使用するデータを取得するとき
  • Universal Rendering (SSR) におけるデータ取得を行うとき(サーバーサイドでデータを取得した結果をクライアントでも扱えるようにするなど)
  • リアクティブな値に連動して、自動的に再取得を行うとき
  • 一度取得したキャッシュを使用したり、必要に応じて再取得したいとき

Nuxt によるキャッシュを活用して、アプリケーション内の値の取り扱いをより便利にするためのコンポーザブルといえます。

$fetch のほうがふさわしい場面

たとえばつぎのような場面では(useFetch ではなく)$fetch が適しています。
(useFetch で行えるものも含んでいます)

  1. サーバー側 (server/api など) でデータを取得する
  2. ページ内の button をクリックしたときに、サーバー側に何らかのデータを POST し、その後別のページに遷移する
  3. アプリケーションで扱う値とは別のデータ取得やPOSTの処理

上記において 1 は必ず $fetch を使用することになります。
2 のように、ユーザーのアクションに応じた処理を行う場合は $fetch が適しています(取得したデータを引き続きコンポーネント内で扱う場合は useFetch で構いません)
3 は、たとえば何かしらのログを一回だけ送信するといった処理を行う場合です。

思えば以前書いた記事のなかには、これらの場面でも useFetch を使ってしまっているものもありました。
(もちろん動作はするのですが、 $fetch を使用したより簡潔な記述ができたかもしれません)

useFetch の基本的な使い方

まずは useFetch を使って、コンポーネントにデータを記述する際の、基本的な使い方をみてみましょう。
(ディレクトリ構成は Nuxt 4 の app ディレクトリ使用の構成にしています)

app/pages/users.vue
<script setup lang="ts">
const { users, status } = await useFetchUsers()
</script>

<template>
  <div>
    <h1>ユーザー一覧</h1>
    <div v-if="status === 'pending' || status === 'idle'">
      Loading...
    </div>
    <div v-else>
      <AppUser
        v-for="user in users"
        :key="user.id"
        :user="user"
        class="my-2"
      />
    </div>
  </div>
</template>

useFetch は、データを利用するコンポーネントの setup 内で使用します。
Suspense によりデータ取得完了まで描画を停止する場合は await をつけて呼び出してください。
(SSRであれば html に描画後のデータが埋め込まれます)

AppUser.vue
app/components/AppUser.vue
<script setup lang="ts">
import type { User } from '@/composables/users'

const props = defineProps<{
  user: User
}>()
</script>

<template>
  <div class="p-2 rounded-lg border border-gray-400 bg-gray-900 flex gap-4">
    {{ user.id }}: {{ user.name }} ({{ user.age }})
  </div>
</template>

Nuxt のサーバーサイド(server/api 以下のファイル)によりデータ取得を行います。
(直接外部のAPIに対してリクエストすることも可能ですが、たとえば API Secret key などをつけてリクエストするなどの場合は必ずサーバー経由で外部 API のリクエストを行いましょう)

Nuxt 4 で `pending` は非推奨となりました

戻り値の pending は非推奨となりました。代わりに status === 'pending' を使用してください。
従来の pending 相当が必要な場合は次のように記述しても利用可能です。

const pending = computed(() => status.value === 'pending')
app/composables/users.ts
export type User = {
  id: string
  name: string
  age: number
}

export const useFetchUsers = async () => {
  const { data, status } = useFetch('/api/users')

  return {
    users: data,
    status,
  }
}

サーバー側では $fetch を使用して、たとえばつぎのようにデータを返すことを想定します。

server/api/users.ts
export default defineEventHandler(async (event): Promise<User[]> => {
  const users = await $fetch('https://api.example.com/users')
  return users ?? []
})

データはたとえば次のようなものが返る想定です。

import type { User } from '@/composables/users'

export const users = [
  { id: '1001', name: '佐藤', age: 10 },
  { id: '1002', name: '鈴木', age: 20 },
  { id: '1003', name: '田中', age: 30 },
] satisfies User[]
コンポーザブルを使わずにコンポーネント内で fetch する場合

この記事では、可能な限りロジックをコンポーザブル内に記述していますが、もちろんコンポーネントで行っても構いません。

コンポーザブルにすることで再利用性が高まり単体テストが書きやすくなるなどの利点があるため、個人的にはコンポーザブルによる記述を行っています。

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

<template>
  <div>
    <h1>ユーザー一覧</h1>
    <div v-if="status === 'pending' || status === 'idle'">
      Loading...
    </div>
    <div v-else>
      <AppUser
        v-for="user in users"
        :key="user.id"
        :user="user"
        class="my-2"
      />
    </div>
  </div>
</template>

Nuxt のデータ取得とコンポーネント描画の流れ

Nuxt がコンポーネントの描画にあたり、どのようにデータを取得し、どのように受け渡しているかを図にしてみました。

とくに Universal Rendering (SSR) においては、サーバー側とクライアント側の役割の違いを理解しておくと useFetch の挙動も理解しやすくなるでしょう。

オプション

useFetch には $fetch に渡すオプションと useAsyncData に渡すオプションがあります。

useAsyncData に渡すオプションについては後述するため $fetch に渡されるオプション(リアクティブな値も適切に処理されます)をリストアップいたします。

  • method: リクエストメソッド(デフォルトは 'GET')
  • query: クエリパラメーター
    • (オブジェクトを渡せます query: { foo: 'bar', baz: ['baz1', 'baz2'] }
  • body: リクエストボディ(同じくオブジェクトを渡せます)
  • headers: リクエストヘッダー
  • baseURL: ベース URL
  • timeout: タイムアウト時間(ミリ秒)
  • cache: Fetch API の cache オプション(参考
  • onRequest({ request, options }): リクエスト前の処理
  • onRequestError({ request, options, error }): リクエスト前のエラー処理
  • onResponse({ request, options, response }): レスポンス後の処理
  • onResponseError({ request, options, response, error }): レスポンス後のエラー処理
  • ほか

戻り値

  • data: 取得したデータ
  • status: リクエストの状態('idle', 'pending', 'error', 'success')
  • error: エラー情報
  • execute(): リクエストを再実行する関数
  • refresh(): リクエストを再実行する関数(execute と同一)
  • clear(): キャッシュをクリアする関数

取得した値に対し Computed Property を使用し、加工した値を付加して返す

下記の例は、取得したユーザーの年齢が 18 歳以上のユーザーの数を返す Computed Property を追加しています。

app/composables/users.ts
export type User = {
  id: string
  name: string
  age: number
}

export const useFetchUsers = async () => {
  const { data, status } = useFetch('/api/users')

  const adultUsersCount = computed(() => data.value?.filter((user) => user.age >= 18).length ?? 0)

  return {
    users: data,
    adultUsersCount, // Computed Property を返す
    status,
  }
}

useFetch 内で使用される $fetch を拡張し、共通処理を記述する

Nuxt では useFetch および $fetch を拡張し、共通処理等を記述することができます。

https://nuxt.com/docs/guide/recipes/custom-usefetch#custom-usefetch

たとえば、API にリクエストを送る際に、共通のヘッダー情報を付与したり、エラーハンドリングを行いたい場合があります。

app/plugins/api.ts
export default defineNuxtPlugin((nuxtApp) => {
  const api = $fetch.create({
    baseURL: 'https://api.example.com',
    onResponse() {
      console.log('fetched')
    },
    async onResponseError({ response }) {
      // 何らかのエラー処理(Sentry への通知等)
    },
  })
  // useNuxtApp().$api で使用可能にする
  return {
    provide: {
      api,
    },
  }
})
app/composables/api.ts
import type { UseFetchOptions } from 'nuxt/app'

export function useApiFetch<T>(
  url: string | (() => string),
  options: Omit<UseFetchOptions<T>, 'default'> & { default: () => T | Ref<T> },
) {
  return useFetch(url, {
    ...options,
    $fetch: useNuxtApp().$api,
  })
}

使うときは useFetch の代わりに useApiFetch を使います。

immediate オプションと execute を使って、データ取得タイミングを制御する

データ取得のタイミングを制御したいときは、immediate オプションを使用します。

immediate オプションはコンポーザブルを呼び出した段階では fetch をせず execute (refresh) により fetch するオプションです。
executerefresh は名前が違うだけでまったく同一の関数です)

たとえば複数の useFetch を用意し、Promise.all を使用して一度に fetch する場合などが考えられます。

app/composables/immediate.ts
export const useUserImmediateFalse = async (userId: MaybeRef<string>) => {
  const user_id = toValue(userId)
  const {
    data,
    status,
    execute, // refresh と同じ
  } = useFetch(`/api/user/${user_id}`, {
    immediate: false,
  })

  return {
    user: data,
    status,
    fetchUser: execute,
  }
}
app/pages/immediate.vue
<script setup lang="ts">
const { user, status, fetchUser } = useUserImmediateFalse('1001')
// 任意のタイミングで fetch できる(この例はとくに意味はない)
await fetchUser()
</script>

<template>
  <div>
    <h1>immediate: false</h1>
    <FetchError :status />
    <FetchLoading :status />
    <div>
      <AppUser :user="user.data" />
    </div>
  </div>
</template>
FetchError.vue, FetchLoading.vue

上記の例で使用しているコンポーネント(記事内のコードが短くなるように用意しました)

app/components/FetchError.vue
<script setup lang="ts">
const props = defineProps<{
  status?: ReturnType<typeof useFetch>['status']
  error?: ReturnType<typeof useFetch>['error']
}>()

const isError = computed(() => props.error === true || props.status === 'error' || props.status == null)
</script>

<template>
  <div v-if="isError">
    Something wrong...
  </div>
</template>
app/components/FetchLoading.vue
<script setup lang="ts">
const props = defineProps<{
  status: ReturnType<typeof useFetch>['status']
  idle?: boolean
}>()

const loading = computed(() => props.status === 'pending' || (props.idle && props.status === 'idle'))
</script>

<template>
  <div v-if="loading">
    Loading...
  </div>
</template>
server/api/user/[user_id].ts
server/api/user/[user_id].ts
import type { User } from '@/types/index'

export default defineEventHandler(async (event): Promise<User> => {
  const user_id = getRouterParam(event, 'user_id') || ''
  const user = $fetch(`https://api.example.com/user/${user_id}`)
  return user ?? null
})

server オプションを使って、クライアント側のみでデータ取得を行う

オプションに server: false とすることで、クライアント側のみでデータ取得を行うことができます。

Universal Rendering (SSR) においては、原則サーバーサイドでデータ取得を行います。
(取得されたデータは payload としてクライアント側に渡されます)

このオプションを使用すると、ブラウザからデータ取得をしたい場合に便利です。

app/composables/server.ts
export const useFetchUserServerFalse = async (userId: MaybeRef<string>) => {
  const user_id = toValue(userId)
  const {
    data,
    status,
  } = useFetch(`/api/user/${user_id}`, {
    server: false,
  })

  return {
    user: data,
    status,
  }
}
app/pages/server.vue
<script setup lang="ts">
const { user, status } = await useFetchUserServerFalse('1001')
</script>

<template>
  <div>
    <h1>server: false</h1>
    <FetchError :status />
    <FetchLoading :status idle />
    <div v-if="user">
      <AppUser :user />
    </div>
  </div>
</template>

lazy オプションを使って、データ取得中の画面描画をブロックしない

上述の server: false と組み合わせるとデータ取得および画面描画を遅延させることができます。
SSR においては SEO に必要のないコンポーネントで有用です。

app/pages/lazy.vue
<script setup lang="ts">
const { user, status } = useLazyFetchUser('1003')

console.log(import.meta.client ? 'client' : 'server', !!user.value)
// server は false, client は true になる(template 内では user の有無を判定する必要あり)
</script>

<template>
  <div>
    <h1>lazy: true</h1>
    <FetchError :status />
    <FetchLoading :status />
    <div v-if="user">
      <AppUser :user="user" />
    </div>
  </div>
</template>
app/composables/lazy.ts
export const useLazyFetchUser = async (userId: MaybeRef<string>) => {
  const user_id = toValue(userId)
  const {
    data,
    status,
  } = useFetch(`/api/user/${user_id}`, {
    lazy: true,
  })

  return {
    user: data,
    status,
  }
}

url や query 等の変更で自動的に再取得する

useFetch は url や query 等(オプション)の変更により自動的に再取得を行います。

この挙動を利用しない場合は watch: false オプションを使用します。

app/pages/params.vue
<script setup lang="ts">
const userId = ref('1001')

const { username, status } = await useFetchUserNameById(userId)

// VueUse の useIntervalFn を使用し3秒ごとに userId を切り替える
useIntervalFn(() => {
  userId.value = userId.value === '1003' ? '1001' : '1003'
}, 3000)
</script>

<template>
  <div>
    <h1>user name: {{ userId }}</h1>
    <div>
      <div class="my-2 p-2 rounded-lg border border-green-400 bg-green-900">
        📛 {{ username }}
      </div>
    </div>
  </div>
</template>
app/composables/username.ts
export const useFetchUserNameById = async (user_id: Ref<string>) => {
  const query = {
    user_id, // ref をそのまま渡せます
  }
  const {
    data,
    status,
  } = useFetch('/api/username', {
    query,
  })

  return {
    username: data,
    status,
  }
}

transform (pick) を使用して、取得したデータを加工する

transform オプションを使用することで、取得したデータを加工することができます。
pick は指定したプロパティのみに絞り込むオプションです(取得結果がオブジェクトの場合は便利ですが、配列等に使用することはできません)

下記の例では、レスポンスが { meta, data } という形式で返ってきた場合に、data のみを取り出しています。
また default オプションを使用して、初期値を設定しています。

app/composables/users.ts
export const useFetchUsers = async () => {
  const {
    data,
    status,
    execute,
  } = useFetch('/api/users', {
    default: (): User[] => [],
    transform: (input): User[] => {
      return input.data ?? []
    },
  })

  return {
    users: data,
    status,
  }
}
server/api/users.ts
type User = {
  id: string
  name: string
  age: number
}

type ApiResponse<T> = {
  meta: {
    code: 200 | 400
    message?: string
  }
  data: T | null
}

export default defineEventHandler(async (event): Promise<ApiResponse<User[]>> => {
  const users = await $fetch('https://api.example.com/users')

  return {
    meta: {
      code: 200,
    },
    data: users ?? null,
  } satisfies ApiResponse<User[]>
})

Universal Rendering (SSR) で transform を使用するメリット

Universal Rendering においては、サーバーサイドで取得したデータ(オブジェクト)をクライアント側に payload として渡しています。
transform を使用すると、その payload のサイズを小さくすることができます。
メモリ消費量を抑えるためにも、利用しないデータは削除するとよいでしょう。

getCachedData を使用して、必要に応じてキャッシュされたデータを返す

getCachedData を使用することで(ページ遷移を伴って繰り返し useFetch するときなど)キャッシュされたデータを取得することができます。
たとえば、1分以内は再取得しないといった処理を行う際に有用です。

app/composables/getCachedData.ts
import type { User, ApiResponse } from '@/types'

const useFetchedAtState = () => useState('fetchedAt', () => 0)

export const useFetchCachedUserNames = async (ttl = 60_000) => {
  const fetchedAtState = useFetchedAtState()
  const {
    data,
    status,
  } = useFetch('/api/usernames', {
    default: (): User['name'][] => [],
    transform: (input): User['name'][] => input?.data ?? [],
    onResponse() {
      // API からデータを取得したタイミングを記録
      fetchedAtState.value = Date.now()
    },
    // transform と getCachedData を使用すると型を合わせるのが少し大変でした 🥲
    getCachedData: (key, nuxtApp) => {
      const data: User['name'][] = nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
      if (!data) {
        // null を返すと再取得を行う
        return null as unknown as User['name'][]
      }
      const fetchedAt = new Date(fetchedAtState.value)
      fetchedAt.setTime(fetchedAt.getTime() + ttl)
      const expirationDate = fetchedAt.getTime()
      const isExpired = expirationDate < Date.now()
      if (isExpired) {
        return null as unknown as User['name'][]
      }
      // 値を返すとその値が使用される
      return data
    },
  })

  return {
    userNames: data,
    status,
  }
}

execute を使用する場合は、すこし試行錯誤が必要でした。
(もっとよいやり方がありそうですが…)

app/composables/getCachedData.ts
import type { User, ApiResponse } from '@/types'

const useFetchedAtState = () => useState('fetchedAt', () => 0)

export const useCachedUserNames = async (ttl = 60_000) => {
  const fetchedAtState = useFetchedAtState()
  const fetchKey = ref<string>()

  const {
    data,
    status,
    execute,
  } = useFetch('/api/usernames', {
    immediate: false,
    default: (): User['name'][] => [],
    transform: (input): User['name'][] => input?.data ?? [],
    onResponse() {
      fetchedAtState.value = Date.now()
    },
    getCachedData: (key, nuxtApp) => {
      fetchKey.value ??= key
      const data: User['name'][] = nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
      if (!data) {
        return null as unknown as User['name'][]
      }
      const fetchedAt = new Date(fetchedAtState.value)
      fetchedAt.setTime(fetchedAt.getTime() + ttl)
      const expirationDate = fetchedAt.getTime()
      const isExpired = expirationDate < Date.now()
      if (isExpired) {
        return null as unknown as User['name'][]
      }
      return data
    },
  })
  // useNuxtData() を使用し明示的にキャッシュを利用する
  const fetchUserNames = async (cached = true) => {
    if (cached && fetchKey.value) {
      const { data: cachedData } = useNuxtData<User['name'][]>(fetchKey.value)
      if (cachedData.value != null && cachedData.value.length > 0) {
        data.value = cachedData.value
        return
      }
    }
    execute()
  }

  return {
    userNames: data,
    status,
    fetchUserNames,
  }
}
すこしハック的な対処方法

内部で使用されている _initial を使用しても同様の対処は可能です(2024年7月15日現在)

  const fetchUserNames = async (cached = true) => {
    if (cached) {
      execute({ _initial: true })
      return
    }
    execute()
  }

dedupe: 'defer' を使用して、重複リクエストを防ぐ

複数のコンポーネントから同じ useFetch を呼び出すと、重複してリクエストされます。
初期値である dedupe: 'cancel' は、保留中のリクエストがある場合、前回のリクエストをキャンセルして新しいリクエストを行います。

dedupe: 'defer' は保留中のリクエストがある場合、新しいリクエストを行わないようにします。
取得した値をどのコンポーネントでも利用したい場合は defer を使用するとよいでしょう。

Nuxt 4 で dedupe に boolean を設定するのは非推奨となりました

refresh(execute) の引数で指定する dedupe との混乱を避けるため 'cancel''defer' などの文字列を使用することになりました

app/composables/dedupe.ts
import type { User } from '@/types'

export const useDedupeUserNames = async () => {
  const {
    data,
    status,
  } = useFetch('/api/usernames', {
    dedupe: 'defer',
    default: (): User['name'][] => [],
    transform: (input): User['name'][] => input?.data ?? [],
  })

  return {
    userNames: data,
    status,
  }
}
app/pages/dedupe.vue
<template>
  <div>
    <h1>Dedupe: 'defer' - users list</h1>
    <div>
      <DedupeA />
      <DedupeA />
      <DedupeB />
    </div>
  </div>
</template>
app/components/DedupeA.vue
<script setup lang="ts">
const { userNames, status } = await useDedupeUserNames()
</script>

<template>
  <div>
    <h1>dedupe: a</h1>
    <div>
      <AppUserName
        v-for="(name, index) in userNames"
        :key="index"
        :name
      />
    </div>
  </div>
</template>

Nuxt 4 で deep オプションのデフォルトが false になり datashallowRef 相当になりました

Nuxt 4 では deep オプションのデフォルトが false になり、datashallowRef 相当になりました。
(Nuxt 3 では true です)

もともと data を(ユーザーが)書き換えることは想定されていないと思いますが、この変更でパフォーマンスの改善が見込まれます。
(これまで、ほぼほぼ変更されることのない下層の値に対してもリアクティビティが維持されていましたが deep: false によりトップレベルの変更に対してのみリアクティブになるためです)

さいごに

Nuxt の主要なコンポーザブルのひとつ useFetch について、基本的な使い方からオプションの使い方、拡張方法までを解説しました。
徐々に機能が増えてきたこともあり、使い慣れている方でもすべてを活用できていないかもしれません。
定期的に公式サイトをみて、新しい情報を取得していきたいですね。

リリースの迫っている Nuxt 4 の useFetch まわりの変更は、パフォーマンス改善にも繋がります。
まだ Nuxt 3.12.x にアップデートしていない方は、ぜひアップデートしたうえで Nuxt 4 の変更を試してみてくださいね。

nuxt.config.ts
export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4, // これで試せます(従来のディレクトリ構成のままでも利用可能)
  },
})

間違いや不充分な説明を見つけたら

間違いや不充分な説明を見つけたらぜひコメント欄等でお知らせください。
正確な情報を伝えることを大切にしています。些細なことで構いませんので、ぜひご協力お願いします。

Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion