Vue — 非同期 UX 3 状態
テクノロジ系 / L11 api — 非同期 UX と API 連携設計
Vue — 非同期 UX 3 状態
api / ch01
このチャプターの到達ゴール
| 翌日 |
|---|
| API を叩くとき 通信中・成功・失敗の 3 状態 を当然のように UI に出せる |
finally がなぜ要るか を 1 文で言える |
| わざと壊して 3 状態の遷移を見る習慣がつく |
目的 (= 3 状態を必ず見せる)
API 通信は 必ず失敗する (= ネット切れ / API 落ち / バリデーション失敗 / レート制限)。
ユーザーに「今どうなっているか」 を見せるのが UX の最低限。
| 状態 | 出すべき UI |
|---|---|
| 通信中 (= loading) | 「読み込み中…」 / ボタン disabled |
| 成功 | リダイレクト / トースト / 通常表示 |
| 失敗 (= error) | エラーバナー / リトライボタン |
> 3 状態のうち 失敗を最初から考える のが プロのコード。 成功パスしか書かない人は事故る。
store に loading / error 状態を持つ
export const useTasksStore = defineStore('tasks', () => {
const tasks = ref([])
const loading = ref(false) // ← 追加
const error = ref('') // ← 追加
async function load() {
loading.value = true
error.value = ''
try {
const res = await fetch('http://localhost:8000/tasks')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
tasks.value = await res.json()
} catch (e) {
error.value = `読み込みに失敗しました: ${e.message}`
} finally {
loading.value = false // ← try でも catch でも必ず通る
}
}
// ...
})なぜ finally が要るのか
仮に finally を忘れて こう書くと、 エラー時にスピナーが回り続ける:
async function load() {
loading.value = true
try {
const res = await fetch('...')
tasks.value = await res.json()
loading.value = false // ← 成功時しか通らない
} catch (e) {
error.value = '失敗しました'
// loading.value = false を忘れがち
}
}finally は try でも catch でも必ず通る ブロック。
「成功でも失敗でも 必ずやること」 (= スピナーを止める / リソースを閉じる) はここに書く。
> これは Pinia 固有ではなく try/catch/finally の基本。 Vue/Pinia の外でも毎日使う。
view 側でローディング表示
<script setup>
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useTasksStore } from '@/stores/tasks'
const store = useTasksStore()
const { tasks, loading, error } = storeToRefs(store)
onMounted(() => store.load())
</script>
<template>
<h1>タスク一覧</h1>
<p v-if="loading">読み込み中...</p>
<p v-else-if="error" class="err">
{{ error }}
<button @click="store.load()">再読み込み</button>
</p>
<ul v-else>
<li v-for="t in tasks" :key="t.id">{{ t.name }}</li>
</ul>
</template>v-if / v-else-if / v-else の順序が肝
| 順序 | 起きる現象 |
|---|---|
v-if="loading" 最初 | ローディング中はリストを出さない (= 一瞬リストが見える事故を防ぐ) |
v-else-if="error" 次 | エラー時はリトライボタンだけを出す |
v-else 最後 | 上 2 つが false なら通常表示 |
> 「ローディングのまま中身が見える」 と 「エラーなのに古いデータが見える」 はよくある UX バグ。
> 排他的に v-if/v-else で繋ぐ ことで防げる。
フォーム送信の二重送信防止
// forms/ch01 で書いた onSubmit に submitting / errorMsg を足す
const submitting = ref(false)
const errorMsg = ref('')
async function onSubmit() {
touched.value = true
if (!isValid.value || submitting.value) return // ← 多重クリック防止
submitting.value = true
errorMsg.value = ''
try {
await store.add(name.value.trim())
router.push('/')
} catch (e) {
errorMsg.value = `追加できませんでした: ${e.message}`
} finally {
submitting.value = false
}
}ボタンラベル切替 + エラー表示
<template>
<input v-model="name" @blur="touched = true" />
<p class="warn">{{ errorMessage }}</p>
<button :disabled="!isValid || submitting" @click="onSubmit">
{{ submitting ? '追加中...' : '追加' }}
</button>
<p v-if="errorMsg" class="err">{{ errorMsg }}</p>
</template>> ボタンラベルが変わる = ユーザーが「処理中だ」 と認識できる。 これも 3 状態のうちの 1 つ。
store の add も try/catch 化
async function add(name) {
if (!name || !name.trim()) throw new Error('空の名前は登録できません')
const res = await fetch('http://localhost:8000/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`API エラー: HTTP ${res.status} ${body}`)
}
const created = await res.json()
tasks.value.push(created)
}> store 内で throw すれば、 呼び出し側 (view) の try/catch でメッセージ化できる。
> store と view で責務を分けるのが良い書き方。
わざと壊して確認 (= 3 状態を体に入れる)
| 試す | やり方 | 期待動作 |
|---|---|---|
| API 停止時 | API サーバーを止めて 「再読み込み」 | エラーメッセージ + リトライボタン |
| 重複名 | 同じ名前を 2 回追加 | API が 409 を返す → エラー表示 |
| 遅延 | DevTools の Network タブで Throttling を遅くする | 「読み込み中…」 が見える |
> ⚠️ エラー UI は わざと壊して見る のが大事。 開発中に通常系しか見ない人が多い。
チャプター完了サイン
- [ ] 起動直後に 「読み込み中…」 が一瞬見える
- [ ] API を止めると エラーメッセージ + 再読み込みボタン が出る
- [ ] 「追加」 ボタンが連打防止になっている (= 連打しても 1 度しか POST されない)
- [ ] 「追加中…」 表示が押下後に出る
- [ ] 「なぜ finally が要るのか」 を 1 文で言える
次に学ぶ領域
| 領域 | 名前 |
|---|---|
| 通信ライブラリ | axios / TanStack Query |
| リクエストキャンセル | AbortController |
| リトライ戦略 | 指数バックオフ / p-retry |
| 楽観的更新 | Optimistic UI + rollback |
| 非同期コンポーネント | Suspense / async setup |
| 別プロトコル | GraphQL / WebSocket / SSE / RPC |
> 全部覚える必要はない。 「今日の try/catch/finally がライブラリの中で同じことをしている」 という地図だけ持っておく。
考えてみよう
既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。
ch01 — 演習
Lv1: store に loading / error を持つ
stores/tasks.js の load() を 3 状態対応に書き換え:
const loading = ref(false)
const error = ref('')
async function load() {
// TODO 1: loading.value = true / error.value = ''
// TODO 2: try { fetch → tasks.value = await res.json() } catch { error.value = '...' } finally { loading.value = false }
}
return { tasks, loading, error, load }検証
- 起動直後の DevTools で
loadingが一瞬 true → false に変わる - ネット切断時に
errorに文字列が入る
Lv2: 排他 v-if/v-else-if/v-else
TaskListView.vue で:
<template>
<p v-if="loading">読み込み中...</p>
<p v-else-if="error" class="err">
{{ error }}
<button @click="store.load()">再読み込み</button>
</p>
<ul v-else>
<li v-for="t in tasks" :key="t.id">{{ t.name }}</li>
</ul>
</template>検証
| シナリオ | 期待表示 |
|---|---|
| 起動直後 (= 通信中) | 「読み込み中…」 |
| 成功 | リスト |
| API 停止して再読み込み | エラー文 + ボタン |
> ⚠️ v-if の順序を間違えると 「エラーなのに古いデータが見える」 「ローディングのままリストも見える」 が起きる。
Lv3: 二重送信防止
TaskAddView.vue の onSubmit を:
const submitting = ref(false)
const errorMsg = ref('')
async function onSubmit() {
// TODO 3: if (!isValid.value || submitting.value) return
// TODO 4: submitting.value = true / errorMsg.value = ''
// TODO 5: try { await store.add(...) / router.push('/') } catch { errorMsg.value = '...' } finally { submitting.value = false }
}ボタンに:
<button :disabled="!isValid || submitting" @click="onSubmit">
{{ submitting ? '追加中...' : '追加' }}
</button>検証
- 追加ボタンを 10 回連打 → POST は 1 回しか飛んでいない (= DevTools Network タブで確認)
- ボタンラベルが 押下直後 「追加中…」 に変わる
Lv4: わざと壊す
下記 3 つを意図的に発生させて、 UI がどう振る舞うか観察せよ。
| 試す | 操作 |
|---|---|
| API 停止 | API サーバーを Ctrl+C で止めて再読み込み |
| 409 重複 | 既存タスク名で「追加」 を押す |
| 遅延 | DevTools Network タブで Throttling を Slow 3G に切替 |
提出物
「なぜ finally が要るのか」 と 「v-if の順序を間違えると何が起きるか」 を それぞれ 1 文で書け。
チャレンジ
AbortControllerを使って 「再読み込みボタンを連打したら前のリクエストをキャンセル」 を実装してみるaxiosの interceptor を 1 つ書いて、 全リクエストにAuthorization: Bearer xxxを自動追加
ヒント
finallyのvalue = falseを try の終わりに書く と忘れがち。 必ずfinallyブロックに書くif (!isValid || submitting)のsubmittingを 忘れる と 二重送信が起きる- store 内
throwした文字列が view のerrorMsgにそのまま出る → メッセージはユーザー向け文言に揃えると親切