APIからデータを取得する画面では、「読み込み中」「失敗」「成功」の3つの状態を、必ず扱う必要があります。コンポーネントごとにバラバラに書くと、抜けや食い違いが生まれます。この記事では、Vue 3を前提に、その状態管理をComposable関数にまとめるパターンを整理します。単純な取得の基本は非同期データの取得と表示、JSONファイルの取得はJSONファイルからデータを取得する方法で解説しています。
よくある書き方の問題点
まず、loading、error、dataの3つのrefで状態を持ち、try-catch-finallyで更新する書き方です。基本はこれで動きますが、実際に使うと問題が出ます。
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エラーには、ステータスを残します。呼び出し側が、エラーの種類を区別できます。
// 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が変わり、自動で再取得されます。
<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)が中心です。間隔を空けながら、回数を制限してください。
// 再試行してよいのは、「やり直せば成功するかもしれない」エラーだけ
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コール回数を削減する方法が参考になります。
関連記事
- エラーの設計はAPIエラーハンドリングの設計例
- レスポンスによるUIの分岐はAPIレスポンスに応じたUI分岐処理の書き方
よくある質問(FAQ)
statusに一本化すると、状態が常に1つになります。AbortControllerで前のリクエストを中止してください。watchEffectのonCleanupを使うと、URLの変更や破棄のタイミングで自動的に中止できます。abortしたときは、fetchがAbortErrorで失敗します。err.name === 'AbortError'なら、何もせずに戻ってください。まとめ
API取得の状態は、statusの1つの値にまとめると、矛盾が起きません。URLをrefで受け取り、watchEffectとAbortControllerを組み合わせると、URLの変更への追従と、古いリクエストの中止が、同時に実現できます。空データやエラーの種類も表示に反映して、使いやすい画面にしてください。

