【Vue.js】非同期APIの状態管理パターン|loading・error・successの統一的な扱い方

【Vue.js】非同期APIの状態管理パターン|loading・error・successの統一的な扱い方 Vue.js

APIからデータを取得する画面では、「読み込み中」「失敗」「成功」の3つの状態を、必ず扱う必要があります。コンポーネントごとにバラバラに書くと、抜けや食い違いが生まれます。この記事では、Vue 3を前提に、その状態管理をComposable関数にまとめるパターンを整理します。単純な取得の基本は非同期データの取得と表示、JSONファイルの取得はJSONファイルからデータを取得する方法で解説しています。

スポンサーリンク

よくある書き方の問題点

まず、loading、error、dataの3つのrefで状態を持ち、try-catch-finallyで更新する書き方です。基本はこれで動きますが、実際に使うと問題が出ます。

JavaScript:よくある基本形
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)      // ← 初期値が false だと、取得前に「空のリスト」が一瞬出る

  const fetchData = async () => {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(url)   // ← url は固定の文字列。変わっても追従しない
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  return { data, error, loading, fetchData }
}
  • 初期表示で空のリストが一瞬出る:loadingがfalseのまま描画されるため、取得を始める前に、リストの分岐が表示されます。
  • URLの変化に追従しない:検索条件やページ番号が変わるたびに、手動でfetchData()を呼び直す必要があります。
  • 古い応答が新しい結果を上書きする:続けて取得したとき、先に出した遅いリクエストの応答が、後から返ってくると、新しい結果を上書きします。
  • ありえない状態を作れる:3つの変数を別々に持つので、「loadingが真でerrorもある」といった矛盾した組み合わせが起こりえます。

状態を1つのstatusで表すComposable

これらを解決するuseApiです。ポイントは次のとおりです。

  • 状態をstatusという1つの値(idle、loading、success、error)で表します。矛盾した組み合わせが作れません。
  • 引数のURLをrefやgetterでも受け取れるようにし、toValueで値を取り出します(Vue 3.3以降)。watchEffectの中で読むので、URLが変わると自動で再取得されます。
  • watchEffectのonCleanupで、次の実行の前にAbortControllerをabortします。古いリクエストが中止されるため、応答が前後して結果を上書きする問題が起きません。
  • 自分で中止したときのAbortErrorは、通常のエラーにしません。
  • HTTPエラーには、ステータスを残します。呼び出し側が、エラーの種類を区別できます。
JavaScript:composables/useApi.js
// composables/useApi.js
import { ref, shallowRef, watchEffect, toValue } from 'vue'

export function useApi(url) {
  const data = shallowRef(null)
  const error = ref(null)
  // 状態は1つの値で表す:'idle' | 'loading' | 'success' | 'error'
  const status = ref('idle')
  const trigger = ref(0)           // 手動の再取得に使う

  watchEffect((onCleanup) => {
    trigger.value                  // 読むことで、refetch() でも再実行される
    const target = toValue(url)    // ref / computed / getter / 文字列のどれでも受け取れる

    if (!target) {                 // URL がまだ決まっていなければ、何もしない
      status.value = 'idle'
      return
    }

    // 次の実行の前(URL が変わったとき)や、破棄されるときに、前の通信を中止する
    const controller = new AbortController()
    onCleanup(() => controller.abort())

    status.value = 'loading'
    error.value = null

    fetch(target, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) {
          const err = new Error(`HTTP ${res.status}`)
          err.status = res.status   // 種類を区別できるよう、ステータスを残す
          throw err
        }
        return res.json()
      })
      .then((json) => {
        data.value = json
        status.value = 'success'
      })
      .catch((err) => {
        if (err.name === 'AbortError') return   // 自分で中止したものは、エラーにしない
        error.value = err
        status.value = 'error'
      })
  })

  function refetch() {
    trigger.value++
  }

  return { data, error, status, refetch }
}

watchEffectが追跡するのは、同期的に実行されている間にアクセスした値だけです。そのため、toValue(url)をfetchより前(同期の部分)で読んでいます。詳しくはwatch・watchEffectの違いと使い分けをご覧ください。また、Composableは、setupの中で同期的に呼ぶ必要があります。

コンポーネントでの使い方

statusで表示を切り替えます。取得に成功しても、データが空(0件)の場合の表示も、忘れずに用意します。userIdを増やすと、URLが変わり、自動で再取得されます。

Vue:statusで表示を切り替える
<script setup>
import { ref, computed } from 'vue'
import { useApi } from './composables/useApi'

const userId = ref(1)

// userId が変わると、URL も変わり、自動で再取得される
const url = computed(() => `https://jsonplaceholder.typicode.com/posts?userId=${userId.value}`)

const { data, error, status, refetch } = useApi(url)
</script>

<template>
  <button @click="userId++">次のユーザー</button>

  <p v-if="status === 'loading'">読み込み中…</p>

  <div v-else-if="status === 'error'">
    <p>エラー:{{ error.message }}</p>
    <button @click="refetch">再試行</button>
  </div>

  <p v-else-if="status === 'success' && data.length === 0">データがありません</p>

  <ul v-else-if="status === 'success'">
    <li v-for="post in data" :key="post.id">{{ post.title }}</li>
  </ul>
</template>

statusがidleのときは、何も表示されません。URLがまだ決まっていない状態です。表示の出し分けはv-ifで条件分岐させる方法、リスト表示はv-forで配列やオブジェクトをループして表示する方法をご覧ください。

再試行は「やり直して意味があるエラー」だけに

自動で再試行する機能を足したくなりますが、すべてのエラーで再試行するのは危険です。404や401のような4xxは、やり直しても同じ結果になり、無駄なアクセスが増えるだけです。再試行してよいのは、ネットワークエラーや、サーバー側の一時的な障害(5xx)が中心です。間隔を空けながら、回数を制限してください。

JavaScript:再試行してよいエラーの判定
// 再試行してよいのは、「やり直せば成功するかもしれない」エラーだけ
function isRetryable(err) {
  if (err.name === 'AbortError') return false     // 自分で中止した
  if (err.status === undefined) return true       // ネットワークエラーなど
  return err.status >= 500                        // サーバー側の一時的な障害
  // 4xx(URL違い・認証切れ・入力ミスなど)は、やり直しても同じ結果になる
}

ライブラリを使う選択肢

公式は、Composableを集めたライブラリとして、VueUseを紹介しています。VueUseにもuseFetchがあるため、自作の関数に同じ名前を付けると紛らわしくなります。自作するなら、useApiのように別の名前にするのがおすすめです。キャッシュや再検証まで必要なら、ライブラリの利用も検討してください。仕組みを知るにはSWRの概念をVueで再現する方法、キャッシュは外部APIの結果をキャッシュしてAPIコール回数を削減する方法が参考になります。

関連記事

よくある質問(FAQ)

Qloading・error・dataの3つのrefではだめですか?
A動きますが、矛盾した組み合わせ(読み込み中なのにエラー)を作れてしまいます。statusに一本化すると、状態が常に1つになります。
Q古いリクエストの結果が表示されてしまいます。
A競合状態です。新しいリクエストを出す前に、AbortControllerで前のリクエストを中止してください。watchEffectのonCleanupを使うと、URLの変更や破棄のタイミングで自動的に中止できます。
QAbortErrorがエラーとして表示されてしまいます。
A自分でabortしたときは、fetchがAbortErrorで失敗します。err.name === 'AbortError'なら、何もせずに戻ってください。
Q自動で再試行してもよいですか?
Aネットワークエラーや5xxのような、一時的な障害に限ってください。4xxはやり直しても同じ結果になります。回数を制限し、間隔を空けてください。

まとめ

API取得の状態は、statusの1つの値にまとめると、矛盾が起きません。URLをrefで受け取り、watchEffectとAbortControllerを組み合わせると、URLの変更への追従と、古いリクエストの中止が、同時に実現できます。空データやエラーの種類も表示に反映して、使いやすい画面にしてください。