NuxtやVueで画面を作り始めたころは、ボタンを押した場所や onMounted() の中にそのまま $fetch を書いても困りません。小さい画面なら、どのAPIを呼んでいるかもすぐ追えます。
ただ、一覧取得、登録、更新、削除、検索、認証切れの処理が増えると、画面コンポーネントの中にAPIの都合が混ざってきます。フォームの状態を見たいのに、ヘッダーやエンドポイントまで読まされると、しんどくなります。
今回は、Global Pocket というプロダクトのNuxtフロントエンド実装を題材に、API呼び出しを useApi と機能別composableへ分ける考え方を、初学者向けの小さい例に直して見ていきます。
tomoGlobal Pocketは、筆者が開発しているWebサービスです。
この記事では、そのフロントエンド実装の一部を題材にしています。
サービスの概要は、Global Pocketの紹介ページにまとめていますので気になった方はぜひご覧ください。
画面に直接APIを書く形
実装が少ない段階では、画面から直接APIを呼ぶ形でも動きます。Nuxtでは $fetch を使うとAPIを呼び出せます。Vueの ref は画面で使う状態を持つためのもの、onMounted() はコンポーネントが表示されたあとに処理を動かすためのものです。
ここでいうcomposableは、VueのComposition APIを使った処理を再利用しやすい関数に切り出したものです。最終的には、次の流れへ寄せます。
画面コンポーネント
-> useUsers / useNotes などの機能別composable
-> useApi
-> $fetch
-> API画面が直接 $fetch を呼ぶ形から、いきなり大きな設計に飛ぶ必要はありません。まず共通の入口として useApi を作り、そのあとユーザー用、ノート用、ファイル用のように、機能ごとのcomposableへ分けていきます。
次の例では、画面表示時にユーザー一覧を読み込み、結果を users に入れています。
<script setup lang="ts">
type User = {
id: string;
name: string;
email: string;
};
const users = ref<User[]>([]);
const isLoading = ref(false);
const loadUsers = async () => {
// 画面側では、読み込み中かどうかの状態を持つ。
isLoading.value = true;
try {
const response = await $fetch<{ data: User[] }>("/api/users", {
// この設定を画面ごとに書くと、後から変更するときに探す場所が増える。
baseURL: "https://api.example.com",
credentials: "include",
headers: {
Accept: "application/json",
},
});
users.value = response.data;
} finally {
// 成功しても失敗しても、ローディング表示は必ず戻す。
isLoading.value = false;
}
};
// 画面が表示されたタイミングで、最初の一覧を読み込む。
onMounted(loadUsers);
</script>このくらいなら簡単に読めます。
が、問題は、この形が登録、更新、削除、検索、別の一覧、別の詳細画面にも増えていくことです。



最初は画面に直接書いた方が早いです。困るのは、同じAPI設定を三つ目の画面に書き始めたあたりからです。
たとえば、すべてのAPIで baseURL と credentials が必要なら、画面ごとに同じ設定を書くことになります。認証切れのときにログイン画面へ戻したいなら、その処理も複数画面に広がります。POSTやPATCHでCSRFトークンが必要になれば、さらに同じような処理が増えます。
画面コンポーネントは、本来は画面の状態を扱う場所です。APIの共通設定まで抱え始めると、画面として何をしているのかが読みにくくなります。
読み始めたころに迷いやすいのは、「動いているなら分けなくてよいのでは」と感じるところです。確かに、1画面だけなら分けなくても動きます。分ける理由は、今の1回のAPI呼び出しを短くするためではありません。同じ呼び出し方が増えたときに、直す場所と読む場所を分けるためです。
共通入口のuseApi
最初に分けるのは、API呼び出しの共通処理です。Global Pocketでは、useApi.ts がこの入口になっています。
実装では、useRuntimeConfig() からAPIのbaseURLを読み、Cookieを含めて送るために credentials: "include" を付け、JSONを受け取るための Accept ヘッダーもまとめています。変更系リクエストではCSRFトークンを付け、401のときはログイン画面へ戻す処理もここにあります。
小さい例にすると、考え方は次のようになります。
type ApiResponse<T> = {
data: T;
};
export const useApi = () => {
// APIの向き先を設定から読み、環境ごとの差をコードに散らさない。
const config = useRuntimeConfig();
const request = async <T>(
path: string,
// methodやbodyなど、APIごとの差分だけを呼び出し側から受け取る。
options: Parameters<typeof $fetch<ApiResponse<T>>>[1] = {},
) => {
return await $fetch<ApiResponse<T>>(path, {
baseURL: config.public.apiBase,
credentials: "include",
...options,
headers: {
Accept: "application/json",
// 個別のAPIで必要なヘッダーがあっても、共通ヘッダーを消さずに足す。
...(options.headers ?? {}),
},
});
};
return {
request,
};
};このサンプルには、読み慣れないうちは引っかかりやすい記法がいくつかあります。
useRuntimeConfig() は、Nuxtの設定値を読むための関数です。APIのURLをコードに直接書かず、runtimeConfig.public.apiBase のような設定から読むと、開発環境と本番環境でAPIの向き先を変えやすくなります。
request<T> の T は、APIから返ってくるデータの型を呼び出し側から渡すためのものです。たとえば api.request<User[]>("/api/users") と書くと、「このAPIはユーザー配列を返す」とTypeScriptに伝えられます。
options は、GET以外のAPIで method や body を渡すための追加設定です。useApi 側で共通設定を持ちながら、機能別composableから必要な差分だけ渡せるようにしています。
ここで大事なのは、useApi が「何のデータを取るか」を知らないことです。ユーザー一覧なのか、ノート一覧なのか、ファイル一覧なのかは扱いません。
useApi が持つのは、どのAPIでも共通して必要になる処理です。
- APIのbaseURL
- Cookieを含める設定
- 共通ヘッダー
- 認証切れの扱い
- CSRFトークンの扱い
この層を作っておくと、共通処理を変える場所が1か所になります。
反対に、useApi に入れない方がよいものもあります。たとえば「ユーザー一覧を取得する」「ノートを作成する」といった機能ごとの処理です。そこまで useApi に入れると、共通入口が大きくなりすぎます。useApi は薄い入口にして、具体的なAPIは次の層へ分けます。
機能別composable
useApi だけでは、まだ画面側が /api/users のようなURLを知る必要があります。そこで、機能ごとのAPIをcomposableに分けます。
たとえばユーザー一覧と作成を扱うなら、useUsers のようなcomposableを作ります。
type User = {
id: string;
name: string;
email: string;
};
type UserPayload = {
name: string;
email: string;
};
export const useUsers = () => {
// 共通の通信設定はuseApiに任せ、ここではユーザーAPIの契約だけを扱う。
const api = useApi();
const list = async () => {
// 画面側にURLを書かせないため、一覧取得のエンドポイントをここに閉じ込める。
return await api.request<User[]>("/api/users");
};
const create = async (payload: UserPayload) => {
// 登録時のHTTPメソッドとpayloadの形も、画面ではなくこの層に置く。
return await api.request<User>("/api/users", {
method: "POST",
body: payload,
});
};
return {
list,
create,
};
};これで、ユーザーAPIのURLやHTTPメソッドは useUsers に寄ります。画面側は、/api/users にGETするのかPOSTするのかを毎回書かなくてよくなります。
useUsers のような機能別composableは、画面に近い名前で関数を用意します。list() は一覧を取る、create() は作成する、update() は更新する。画面側から見ると、HTTPの細かい指定ではなく、画面で起きる操作の名前で呼べます。
Global Pocketの実装でも、useUsers.ts は list、create、update、destroy を持っています。ファイル付きの更新では FormData を組み立てていますが、その処理も画面ではなく useUsers 側に寄せています。
画面から見ると、「ユーザーを作る」という操作だけが残ります。
<script setup lang="ts">
// 画面はユーザーAPIの細かいURLを知らず、この入口だけを使う。
const usersApi = useUsers();
const users = ref<User[]>([]);
const isLoading = ref(false);
const loadUsers = async () => {
isLoading.value = true;
try {
// 画面側では「一覧を読む」という操作名で呼ぶ。
const response = await usersApi.list();
users.value = response.data;
} finally {
isLoading.value = false;
}
};
const submitUser = async () => {
// 登録の通信方法はuseUsers側に隠し、画面は入力値を渡すだけにする。
await usersApi.create({
name: "山田太郎",
email: "member@example.com",
});
// 登録後の画面更新はUIの都合なので、画面側に残す。
await loadUsers();
};
</script>画面側に残るのは、読み込み状態、フォームの値、登録後に一覧を読み直す処理です。APIのURLや共通ヘッダーは出てきません。
ここまで分けると、同じ「ユーザーを作る」処理を別の画面から呼びたくなったときも、usersApi.create() を使えます。画面ごとにURLやメソッドを書き直さなくて済みます。
ここで usersApi という変数名にしているのは、画面側で「これはユーザーAPIを呼ぶためのもの」と分かるようにするためです。useUsers() の戻り値には list や create が入っているので、画面では usersApi.list()、usersApi.create() のように読めます。$fetch の細かい指定を読む前に、画面で何が起きているかを追えるのが利点です。
画面は画面の仕事に寄せる
composableに分ける目的は、ファイル数を増やすことではありません。画面が読むべきことを減らすためです。
画面コンポーネントでは、入力中のフォーム、ローディング状態、モーダルを開くか閉じるか、一覧を再読み込みするタイミング、選択中の行、表示用の加工を扱います。
一方で、API用composableでは、どのエンドポイントを呼ぶか、HTTPメソッドは何か、payloadをどう作るか、FormData が必要か、共通API入口に何を渡すかを扱います。
Global Pocketの DetailTabs.vue では、ノート用に useNotes()、ファイル用に useFiles() を取得し、notesApi.listByTopic() や filesApi.create() を呼んでいます。画面にはタブ、検索、モーダル、ローディング、一覧更新の処理が残り、ノートAPIやファイルAPIの細かいURLはcomposable側に寄っています。
この分け方にすると、画面を読むときの目線が変わります。画面では「いつ読み込むか」「登録後に何を更新するか」を読み、API composableでは「どのAPI契約で呼んでいるか」を読みます。
慣れないうちは、1つのファイルで全部見えた方が安心に感じることもあります。ただ、画面が大きくなると「全部見える」は「全部読まないと分からない」に変わります。APIの都合をcomposableへ逃がしておくと、画面の流れを追うときに読む量を減らせます。
共通処理はuseApiで一度だけ考える
実務のAPI呼び出しでは、単純なGETだけでは済みません。
Global Pocketの useApi.ts では、POSTなどの変更系リクエストでCSRFトークンを取得し、X-CSRF-TOKEN ヘッダーを付けています。401のときは、ログインAPI以外ならログイン画面へ戻します。419のときは、保持しているCSRFトークンをクリアします。
こうした処理を画面ごとに書くと、後から直すのが大変です。



認証切れやCSRFの処理は、ばらけると見落としやすいです。画面ごとの差が増える前に入口を寄せておく方が後で楽です。
たとえば、認証切れの遷移先を変えたいとします。画面に直接 $fetch を書いていると、似た処理を探して直すことになります。useApi に寄せていれば、共通入口の処理を直せば済みます。
Nuxtを使い始めた段階で、CSRFや401リダイレクトまで一気に作り込む必要はありません。baseURL、共通ヘッダー、Cookie送信を useApi に寄せるだけでも効果があります。後から認証切れの扱いを足したくなったときも、足す場所が決まっています。
Global Pocketの実装では、useApi がCSRFや401の処理まで持っています。記事内のサンプルではそこまで省いていますが、考え方は同じです。どの画面から呼んでも同じ扱いにしたい処理は、機能別composableではなく共通入口に置きます。
機能別composableはAPI契約の置き場になる
useUsers や useNotes のような機能別composableは、API契約の置き場になります。
ノートAPIの例で見ると、Global Pocketの useNotes.ts は次のような操作を持っています。
- トピックに紐づくノート一覧を取得する
- ノートを作成する
- ノートを更新する
- ノートを削除する
- ノートを解決済みにする
- 解決済みを戻す
短くすると、こういう形です。
type Note = {
id: string;
body: string;
is_resolved: boolean;
};
type NotePayload = {
body: string;
};
export const useNotes = () => {
// 共通のbaseURLや認証まわりはuseApiに任せる。
const api = useApi();
const listByTopic = async (topicId: string) => {
// トピックに紐づくURLの組み立ては、画面ではなくノートAPI側に置く。
return await api.request<Note[]>(`/api/topics/${topicId}/notes`);
};
const create = async (topicId: string, payload: NotePayload) => {
// 作成時に必要なtopicIdとpayloadの組み合わせを、この関数の契約にする。
return await api.request<Note>(`/api/topics/${topicId}/notes`, {
method: "POST",
body: payload,
});
};
const resolve = async (noteId: string) => {
// 「解決済みにする」という画面操作を、専用APIの呼び出しに対応させる。
return await api.request<Note>(`/api/notes/${noteId}/resolve`, {
method: "POST",
});
};
return {
listByTopic,
create,
resolve,
};
};このコードを画面に直接書くこともできます。ただ、画面に置くと、タブやフォームや検索処理の中にAPI契約が混ざります。
composableへ寄せておくと、ノートAPIの変更を探す場所が分かります。エンドポイントが変わったとき、payloadの名前が変わったとき、状態変更APIが増えたとき、useNotes から確認できます。
テストもしやすくなる
API呼び出しをcomposableに分けると、テストの分け方もはっきりします。
Global Pocketの useApi.test.ts では、共通入口としてのふるまいを見ています。たとえば、通常リクエストで baseURL や Accept ヘッダーが付くこと、POST時にCSRFトークンを取得してから本リクエストを送ること、401時にログイン画面へ遷移することを確認しています。
一方、useNotes.test.ts では useApi をモックし、useNotes() が正しいエンドポイントとpayloadを渡しているかを確認しています。CSRFや401リダイレクトの細かい動きまでは見ません。それは useApi の責務だからです。
役割を分けているので、テストも分けられます。
// useNotesのテストでは、実際の通信ではなく「useApiへ何を渡したか」を見る。
vi.stubGlobal("useApi", () => ({
request: requestMock,
}));
const notes = useNotes();
await notes.create("topic-1", {
body: "メモ本文",
});
// URL、method、bodyがAPI契約どおりに組み立てられたかを確認する。
expect(requestMock).toHaveBeenCalledWith("/api/topics/topic-1/notes", {
method: "POST",
body: {
body: "メモ本文",
},
});このテストで見たいのは、useNotes がノート作成APIを正しい契約で呼ぶことです。実際の通信はしません。共通ヘッダーや認証切れの処理も、このテストの対象から外します。
この割り切りがあると、テストが読みやすくなります。失敗したときも、共通入口の問題なのか、機能別APIの指定ミスなのかを分けて見られます。
どこまで分ければよいか
最初から細かく分けすぎる必要はありません。小さい画面でAPIが1つだけなら、画面に直接書いても大きな問題にはなりません。
分け始める目安は、同じようなAPI設定が複数箇所に出てきたときです。
baseURLやcredentialsを毎回書いている- 同じエラー処理を何画面にも書いている
- POSTやPATCHの前処理が増えてきた
- 画面の中でAPIのURLを探す時間が増えた
- テストで通信部分を毎回細かくモックしている
このあたりが出てきたら、useApi を作る頃合いです。そのあと、ユーザー、ノート、ファイル、認証のように、機能ごとのcomposableへ分けます。
分ける単位は、画面単位よりもAPIの役割単位に寄せた方が扱いやすくなります。ユーザーAPIなら useUsers、ノートAPIなら useNotes、認証APIなら useAuth という具合です。
まとめ
NuxtでAPI呼び出しが増えてきたら、画面コンポーネントに直接 $fetch を書き続けるより、共通入口と機能別composableに分けた方が読みやすくなります。
useApi には、baseURL、共通ヘッダー、Cookie送信、認証切れ、CSRFのような共通処理を寄せます。useUsers や useNotes には、どのエンドポイントをどのpayloadで呼ぶかを寄せます。
画面側は、APIの細かい都合から少し離れて、フォーム、ローディング、モーダル、一覧更新のような画面の仕事に集中できます。API契約を確認したいときはcomposableを見る。画面の流れを確認したいときはコンポーネントを見る。この分け方にしておくと、実装もテストも追いやすくなります。



