Vue 3のComposition APIの導入により、ロジックの共通化や整理が容易になりました。しかし、実務の現場では「Composable(useXxx)を作ったものの、特定のコンポーネントに依存しすぎて再利用できない」「1つのComposableに多くの機能が詰め込まれ、肥大化してメンテナンスが困難になる」といった課題が頻繁に発生します。
この記事では、実務で役立つComposableの責務分割の基準、具体的な実装パターン(良い例・悪い例)、および設計時のチェックリストを解説します。
対象読者
- Vue 3 / Nuxt 3の実務プロジェクトでComposition APIを使用している開発者
- Composableの設計基準や、適切な粒度での分割方法に悩んでいる方
この記事で得られること
- Composableを「ステート管理」「データ取得」「純粋なロジック」に分割する設計手法
- 疎結合でテストしやすいComposableの実装パターン
- 設計時にセルフチェックできる「Composable設計チェックリスト」
1. Composableが肥大化する原因と「単一責任の原則」
Composableが再利用しにくくなる最大の原因は、「状態(State)」「非同期処理(API通信)」「ビジネスロジック」が1つのファイルに混在していることです。
例えば、ユーザー情報を取得して表示する画面で、以下のような「何でも行うComposable」を作ってしまうケースが該当します。
避けるべき実装例(密結合で肥大化したComposable)
// useUser.ts (悪い例: 状態、通信、バリデーション、フォーマットが混在)
import { ref } from 'vue';
export function useUser() {
const user = ref<any>(null);
const isLoading = ref(false);
const error = ref<string | null>(null);
const fetchUser = async (userId: string) => {
isLoading.value = true;
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
// フォーマット処理が混在
user.value = {
...data,
fullName: `${data.lastName} ${data.firstName}`,
formattedJoinedAt: new Date(data.joinedAt).toLocaleDateString()
};
} catch (err) {
error.value = 'ユーザー情報の取得に失敗しました';
} finally {
isLoading.value = false;
}
};
// バリデーションロジックも同居
const validateEmail = (email: string) => {
return /\S+@\S+\.\S+/.test(email);
};
return { user, isLoading, error, fetchUser, validateEmail };
}
このコードの問題点:
- APIのURLやレスポンス形式に依存しているため、他のAPIやモックデータへの差し替えが困難。
-
validateEmailのような汎用的なロジックがuseUserに内包されており、他の画面でメールアドレスのバリデーションだけを使いたい場合に再利用できない。 - ユニットテストを作成する際、API通信(fetch)のモック化が必須となり、テストの難易度が上がる。
2. 責務分割の3レイヤーパターン
Composableを再利用しやすくするためには、以下の3つのレイヤーに責務を分割して設計します。
| レイヤー | 主な役割 | 特徴 |
|---|---|---|
| 1. Utility Composable | 状態を持たない純粋なロジック(バリデーション、日付操作など) | 引数を受け取り、値を返す。Vueの ref や reactive に依存しない、または最小限にする。 |
| 2. Fetch / Async Composable | 外部APIとの通信、ローディング状態、エラーハンドリング | 通信状態の管理に特化する。データの加工やビジネスロジックは持たない。 |
| 3. Feature / State Composable | 特定のドメイン(ユーザー、カートなど)の状態管理とユースケースの統合 | UtilityやFetchのComposableを組み合わせて、コンポーネントに提供する最終的なインターフェースを構築する。 |
3. 改善後の実装例
上記の3レイヤーパターンに基づいて、先ほどの悪い例を分割・再構築します。
① Utility Composable(純粋なロジックの分離)
まずは、状態を持たず、単体テストが容易なバリデーションロジックを切り出します。
// useValidation.ts (良い例: ユーティリティの分離)
export function useValidation() {
const validateEmail = (email: string): boolean => {
return /\S+@\S+\.\S+/.test(email);
};
return { validateEmail };
}
② Fetch Composable(通信ロジックの抽象化)
API通信とローディング状態の管理を抽象化します。URLやデータ型をジェネリクスで受け取れるようにすることで、汎用性を高めます。
// useFetch.ts (良い例: 汎用的なデータ取得ロジック)
import { ref } from 'vue';
export function useFetch<T>(urlFactory: (...args: any[]) => string) {
const data = ref<T | null>(null);
const isLoading = ref(false);
const error = ref<Error | null>(null);
const execute = async (...args: any[]) => {
isLoading.value = true;
error.value = null;
try {
const response = await fetch(urlFactory(...args));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
data.value = await response.json();
} catch (err) {
error.value = err instanceof Error ? err : new Error('Unknown error');
} finally {
isLoading.value = false;
}
};
return { data, isLoading, error, execute };
}
③ Feature Composable(統合とドメイン知識の適用)
最後に、上記のComposableを組み合わせて、特定の業務ロジック(ユーザー情報の取得と整形)を構築します。
// useUserDetail.ts (良い例: 責務を統合するドメインComposable)
import { computed } from 'vue';
import { useFetch } from './useFetch';
interface UserResponse {
firstName: string;
lastName: string;
joinedAt: string;
email: string;
}
export function useUserDetail() {
// URL生成ロジックを渡してuseFetchを初期化
const urlFactory = (userId: string) => `https://api.example.com/users/${userId}`;
const { data: rawUser, isLoading, error, execute: fetchUser } = useFetch<UserResponse>(urlFactory);
// データの整形(フォーマット)はcomputedで行う
const user = computed(() => {
if (!rawUser.value) return null;
return {
fullName: `${rawUser.value.lastName} ${rawUser.value.firstName}`,
formattedJoinedAt: new Date(rawUser.value.joinedAt).toLocaleDateString(),
email: rawUser.value.email
};
});
return {
user,
isLoading,
error,
fetchUser
};
}
4. 実務導入時の注意点とよくある失敗
引数のリアクティビティの喪失に注意する
Composableに ref や reactive の値を渡す際、関数の引数としてそのまま渡すとリアクティビティ(値の変更検知)が失われることがあります。
引数が変更されたときにComposable内部の処理を再実行したい場合は、computed や Getter関数(() => value)として渡す設計にしてください。
// 追従させたい場合はGetter関数として受け取る設計にする
export function useSearch(getQuery: () => string) {
// getQuery() を watch することで、呼び出し元の値の変化を検知可能
}
グローバル状態とローカル状態の混同
Composable内で定義した ref は、通常そのComposableが呼び出される(useXxx() が実行される)たびに新しいインスタンスが作成されます(ローカル状態)。
アプリケーション全体で状態を共有したい(グローバル状態)場合は、関数の外側で ref を定義するか、Piniaなどの状態管理ライブラリの利用を検討してください。
5. Composable設計セルフチェックリスト
コードレビューや設計時に以下の項目を満たしているか確認してください。
- 単一責任になっているか: そのComposableを変更する理由は1つだけか?(例:APIの仕様変更と、バリデーションルールの変更が同じファイルに影響しないか)
-
Vueのライフサイクルに依存しすぎていないか:
onMountedなどのライフサイクルフックをComposable内部で多用すると、呼び出し側の自由度が下がります。必要最小限に留めているか? - 副作用(Side Effects)が明示的か: 呼び出すだけでグローバルな状態やDOMを書き換えるような、隠れた副作用がないか?
-
テストが容易か: 外部APIやブラウザ固有のAPI(
windowやdocument)に依存している場合、それらをモック化しやすい設計になっているか?
まとめ
Vue 3のComposableは強力な機能ですが、設計基準を設けないとすぐにスパゲティコード化してしまいます。
- Utility(ロジック)
- Fetch(通信)
- Feature(ドメイン)
この3つのレイヤーを意識して責務を分割することで、テストがしやすく、変更に強い堅牢なフロントエンドアプリケーションを構築することができます。まずは、既存の肥大化したComposableから、状態を持たない純粋な関数を切り出すことから始めてみてください。
※実装時には、プロジェクトで利用しているVueおよびTypeScriptのバージョンに応じた最新の公式ドキュメント(Composition APIガイドなど)を併せてご確認ください。