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

Vue — API クライアントの一元化

テクノロジ系 / L11 api — 非同期 UX と API 連携設計

Vue — API クライアントの一元化

api / ch02

このチャプターの到達ゴール

翌日
src/lib/apiClient.ts単一の axios インスタンスを作り、全コンポーネントが import する
interceptors.response で 401 / 共通エラーを 1 箇所で処理できる
baseURLimport.meta.env から読み、URL 直書きを残さない
error.response?.data?.message ?? error.message段階フォールバックが書ける

> 題材は 読書記録アプリ (books の CRUD)。 一覧・登録・更新・削除がすべて同じ API を叩く。

何が問題か (= 散らばった通信処理)

ch01 では各コンポーネントが直接 fetch('http://localhost:8000/...') を書いていた。

画面が増えると 同じことを毎回コピペすることになる。

コピペされるもの増えると何が起きるか
baseURL (URL 直書き)本番 URL に変えるとき全ファイル grep して直す羽目に
共通ヘッダ (Content-Type 等)1 ファイル付け忘れて挙動が揺れる
401 (認証切れ) の処理画面ごとに対応がバラバラ / 付け忘れた画面は無反応
エラーメッセージの取り出し方e.message だけ見て API が返した文言を捨てる

> 通信の 横断的な関心事 は 1 箇所に集約する。 これが今日の主題。

Before — 各 view が axios を直書き (アンチパターン)

// BookListView.vue
import axios from 'axios'

async function load() {
  // baseURL が URL 直書き = env 化されていない
  const res = await axios.get('http://localhost:8000/books')
  books.value = res.data
}
// BookEditView.vue — 別ファイルでも同じ URL をコピペ
import axios from 'axios'

async function save() {
  await axios.put(`http://localhost:8000/books/${id}`, payload)
}

> ❌ http://localhost:8000 が 2 ファイルに散る。 401 が来ても各画面が個別対応。 直書き URL は本番化のとき爆発する。

After — 単一インスタンスを export する

// src/lib/apiClient.ts
import axios from 'axios'

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE,   // ← env から読む (直書きしない)
  headers: { 'Content-Type': 'application/json' },
  timeout: 10_000,
})
# .env.development
VITE_API_BASE=http://localhost:8000
# .env.production
VITE_API_BASE=https://api.example.test

> axios.create()設定済みのインスタンスを 1 つ作り、それを export する。

> 各 view は import { api } from '@/lib/apiClient' するだけ。 URL も設定も二度と書かない。

各 view は api を import するだけ

// BookListView.vue
import { api } from '@/lib/apiClient'

async function load() {
  const res = await api.get('/books')          // ← 相対パスのみ。 baseURL は付かない
  books.value = res.data
}
// BookEditView.vue
import { api } from '@/lib/apiClient'

async function save() {
  await api.put(`/books/${id}`, payload)       // ← 同じ api。 URL もヘッダも共通
}

> パスは 相対 (/books) だけ書く。 baseURL は インスタンスが補完する。

> 開発と本番の切り替えは .env を変えるだけ。 ソースは 1 行も触らない。

response interceptor で 401 を 1 箇所処理

// src/lib/apiClient.ts (続き)
import router from '@/router'

api.interceptors.response.use(
  (res) => res,                                 // 成功はそのまま通す
  (error) => {
    if (error.response?.status === 401) {
      router.push('/login')                     // ← 認証切れは全画面共通でログインへ
    }
    return Promise.reject(error)                 // ← 必ず reject を返す (これが肝)
  },
)

> interceptors.response.use(onSuccess, onError)全レスポンスを 1 箇所で横取りする。

> 401 を各画面で書かなくても、ここで一括ハンドリングできる。

落とし穴 1 — interceptor が 401 を握り潰す

// ❌ やりがちな失敗
api.interceptors.response.use(
  (res) => res,
  (error) => {
    console.error(error)        // ← ログに出すだけ
    // return を書いていない = undefined を return
  },
)
何が起きるか
エラーが console.error に消えるだけで 呼び出し側の catch に届かない
view の try/catch が発火せず error state が立たない → 画面が無反応のまま固まる
「ボタンを押したのに何も起きない」 という最悪の UX

> ⚠️ interceptor の onError は 必ず return Promise.reject(error) で締める。

> ログを出すのは良いが、握り潰してはいけない。 呼び出し側にエラーを返し切る。

落とし穴 2 — baseURL が一時 URL 直書きで残る

// ❌ env 化したつもりで 1 箇所だけ直書きが取り残される
export const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE,
})

// …別ファイルで画像 URL だけ直書きが生き残る
const cover = `http://localhost:8000/covers/${book.id}.png`   // ← 片手落ち

> 「baseURL は env 化したから完了」 と思った裏で、画像 URL や別エンドポイントだけ直書きが残る

> これは目視では気づきにくい。 後の演習で grep で全検出して回収する習慣をつける。

エラーメッセージの段階フォールバック

API が返す JSON は { "message": "ISBN が重複しています" } のような構造のことが多い。

ネット切れだと error.response 自体が存在しない。 段階的に取り出す

function toMessage(error: unknown): string {
  if (axios.isAxiosError(error)) {
    return error.response?.data?.message ?? error.message
    //     ↑ API が返した文言         ↑ なければ axios の汎用文言
  }
  return '不明なエラーが発生しました'
}
取り出す順
error.response?.data?.messageAPI が返した業務メッセージ「ISBN が重複しています」
error.messageaxios / ネットワーク層の文言「Network Error」
③ 固定文何も取れないとき「不明なエラーが発生しました」

> ?. (optional chaining) と ?? (nullish 合体) で 段階的に落とす。 一番具体的な文言を優先する。

view 側はこれだけ薄くなる

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/apiClient'
import { toMessage } from '@/lib/apiClient'

const books   = ref<Book[]>([])
const loading = ref(false)
const error   = ref('')

async function load() {
  loading.value = true
  error.value = ''
  try {
    const res = await api.get('/books')      // 401 は interceptor が処理済み
    books.value = res.data
  } catch (e) {
    error.value = toMessage(e)               // 段階フォールバックで文言化
  } finally {
    loading.value = false
  }
}
onMounted(load)
</script>

> ch01 で学んだ 3 状態 (loading/success/error) はそのまま。

> 変わったのは 「通信の共通処理が view から消えた」 こと。

責務の地図

レイヤ持つもの
apiClient.ts (インスタンス)baseURL / 共通ヘッダ / timeout
interceptors.response401 リダイレクト / 共通エラー整形
toMessage()エラーの段階フォールバック
view3 状態 (loading/error) と画面固有の catch

> 「どこを直せば全画面に効くか」 が 1 箇所に決まっているのが一元化の価値。

> baseURL を変えたいなら .env、401 の挙動を変えたいなら interceptor、それだけを見ればいい。

わざと壊して確認

試すやり方期待動作
401API を 401 を返すように細工してアクセスinterceptor が /login へ飛ばす
握り潰し再現onError から return を消すview の catch が発火せず画面が固まる (= 罠を体感)
API 文言重複 ISBN で 409 + {message} を返させる画面に API の業務メッセージが出る
ネット切れAPI を停止error.message 由来の汎用文言が出る

> ⚠️ 「握り潰し」 は わざと再現して固まるのを見る。 これを知らないと本番で原因不明の無反応に悩む。

チャプター完了サイン

  • [ ] src/lib/apiClient.ts単一の api インスタンス がある
  • [ ] 全 view が import { api } で同じインスタンスを使っている (= 各 view に axios.create が無い)
  • [ ] baseURLimport.meta.env.VITE_API_BASE を読み、ソースに URL 直書きが 0 件
  • [ ] interceptor の onError が return Promise.reject(error) で締めてある
  • [ ] エラー文言が error.response?.data?.message ?? error.message の順で出る
  • [ ] 「interceptor が 401 を握り潰すと何が起きるか」 を 1 文で言える

次に学ぶ領域

領域名前
段階的開発モック先行 → 本番繋ぎ替え (= 次章 ch03)
キャッシュ / 再取得TanStack Query
トークン更新refresh token + interceptor の retry
リクエストキャンセルAbortController

> 一元化した api インスタンスは モックにも本番にも差し替えやすい。 次章でその段階的開発を扱う。

考えてみよう

既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。

ch02 — 演習

題材: 読書記録アプリ (books の CRUD)。 一覧 (BookListView) と編集 (BookEditView) が同じ API を叩く。

Lv1: 単一 api インスタンスに集約する

src/lib/apiClient.ts を作り、各 view の axios 直書きを撤去する。

// src/lib/apiClient.ts
import axios from 'axios'

export const api = axios.create({
  // TODO 1: baseURL は import.meta.env.VITE_API_BASE から読む (URL 直書きしない)
  // TODO 2: headers に Content-Type: application/json
  // TODO 3: timeout: 10_000
})
// BookListView.vue / BookEditView.vue
// TODO 4: import axios → import { api } from '@/lib/apiClient' に置換
// TODO 5: axios.get('http://localhost:8000/books') → api.get('/books') (相対パスのみ)
# .env.development
VITE_API_BASE=http://localhost:8000

検証

  • 各 view から axios.createhttp://localhost:8000消えている
  • grep -rn "axios.create" src/ のヒットが 1 件 (apiClient.ts のみ)
  • grep -rn "http://localhost:8000" src/ のヒットが 0 件

Lv2: response interceptor で 401 と共通エラーを処理

apiClient.ts に interceptor と段階フォールバックを足す。

import router from '@/router'

api.interceptors.response.use(
  (res) => res,
  (error) => {
    // TODO 6: error.response?.status === 401 なら router.push('/login')
    // TODO 7: 必ず return Promise.reject(error) で締める  ← これを忘れると握り潰し
  },
)

export function toMessage(error: unknown): string {
  // TODO 8: axios.isAxiosError なら error.response?.data?.message ?? error.message
  //         そうでなければ '不明なエラーが発生しました'
  return ''
}

view 側:

import { api, toMessage } from '@/lib/apiClient'
// catch (e) { error.value = toMessage(e) }   ← e.message 直参照をやめる

検証

シナリオ期待表示
API が 401 を返す/login に遷移する
重複 ISBN で 409 + {message}画面に API が返した業務メッセージ が出る
API 停止 (ネット切れ)error.message 由来の汎用文言が出る

> ⚠️ onError から return を一度わざと消し、画面が 無反応で固まるのを観察してから戻すこと。

提出物

interceptor の onError で return Promise.reject(error) を忘れると何が起きるか」 を 1 文で書け。

チャレンジ

  • error.response?.data?.message ?? error.messagetoMessage() に閉じ込め、view から e.message 直参照を 0 件にする (grep -rn "\.message" src/views/ で確認)
  • request interceptor を 1 つ足して、全リクエストに Authorization: Bearer <token> を自動付与する (token はダミー文字列で可)
  • 画像 URL など baseURL とは別に直書きが残っていないか grep -rn "http://localhost" src/ で全検出して回収する

ヒント

  • api.get('/books')先頭スラッシュ を忘れると baseURL との連結が崩れる
  • interceptor の onError は (error) => { … ; return Promise.reject(error) }。 return を書かないと catch に届かない
  • ??null / undefined のときだけ右に落ちる。 空文字 '' は落とさないので、API が空メッセージを返すケースは別途考える