採点されません。匿名です。 間違えることは学習の一部です。読むだけでもかまいません。

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 を忘れがち
  }
}

finallytry でも 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.jsload() を 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.vueonSubmit を:

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 を自動追加

ヒント

  • finallyvalue = falsetry の終わりに書く と忘れがち。 必ず finally ブロックに書く
  • if (!isValid || submitting)submitting忘れる と 二重送信が起きる
  • store 内 throw した文字列が view の errorMsg にそのまま出る → メッセージはユーザー向け文言に揃えると親切