Vue.js の Composition API でビジネスロジックを分離する

Vue.js の Composition API を使ったビジネスロジックの分離について、実際の開発現場で役立つ方法をご紹介します。
多くの開発者が直面する「コードが複雑になって管理しづらい」「同じ処理を何度も書いている」「テストが書きにくい」といった課題を、Composition API の composables を使うことで解決できます。この記事では、実際のプロジェクトで使える実践的なテクニックを、エラーケースも含めて詳しく解説していきます。
Composition API の基本概念
リアクティブな状態管理
Composition API の最大の特徴は、リアクティブな状態管理の柔軟性です。従来の Options API では、data
やcomputed
、methods
が分離されていましたが、Composition API では関連する機能を一つの関数にまとめることができます。
typescript// 基本的なリアクティブ状態の管理
import { ref, reactive, computed } from 'vue';
// refを使った単純な値の管理
const count = ref(0);
const increment = () => count.value++;
// reactiveを使ったオブジェクトの管理
const user = reactive({
name: '',
email: '',
isActive: false,
});
// computedを使った派生状態の管理
const userDisplayName = computed(() => {
return user.name || 'ゲストユーザー';
});
このように、関連する状態とロジックを一箇所にまとめることで、コードの見通しが格段に良くなります。
ライフサイクルフックの活用
Composition API では、ライフサイクルフックも関数として扱えるため、ビジネスロジックと密接に結びつけることができます。
typescriptimport { onMounted, onUnmounted } from 'vue';
// コンポーネントのライフサイクルと連携したロジック
const useDataFetcher = () => {
const data = ref(null);
const loading = ref(false);
const error = ref(null);
const fetchData = async () => {
loading.value = true;
try {
const response = await fetch('/api/data');
data.value = await response.json();
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
// コンポーネントのマウント時に自動実行
onMounted(() => {
fetchData();
});
return { data, loading, error, fetchData };
};
関数の再利用性
Composition API の真価は、関数として分離されたロジックを複数のコンポーネントで再利用できることです。
typescript// 再利用可能なカウンター機能
const useCounter = (initialValue = 0) => {
const count = ref(initialValue);
const increment = () => count.value++;
const decrement = () => count.value--;
const reset = () => (count.value = initialValue);
return {
count: readonly(count),
increment,
decrement,
reset,
};
};
// 複数のコンポーネントで同じロジックを使用
const { count, increment, decrement } = useCounter(10);
ビジネスロジック分離の重要性
コードの可読性向上
ビジネスロジックを分離することで、コンポーネントのテンプレート部分とロジック部分が明確に分かれ、コードの可読性が大幅に向上します。
vue<!-- ロジックが混在した従来の書き方 -->
<template>
<div>
<input v-model="user.name" />
<button @click="saveUser">保存</button>
<div v-if="error">{{ error }}</div>
</div>
</template>
<script>
export default {
data() {
return {
user: { name: '' },
error: null,
};
},
methods: {
async saveUser() {
try {
await this.$api.saveUser(this.user);
this.$router.push('/success');
} catch (err) {
this.error = err.message;
}
},
},
};
</script>
vue<!-- ロジックを分離したComposition APIの書き方 -->
<template>
<div>
<input v-model="user.name" />
<button @click="saveUser">保存</button>
<div v-if="error">{{ error }}</div>
</div>
</template>
<script setup>
import { useUserManager } from '@/composables/useUserManager';
const { user, error, saveUser } = useUserManager();
</script>
テスタビリティの向上
ビジネスロジックが関数として分離されているため、単体テストが書きやすくなります。
typescript// composableのテスト例
import { useUserManager } from '@/composables/useUserManager';
import { mount } from '@vue/test-utils';
describe('useUserManager', () => {
it('ユーザーを正常に保存できる', async () => {
const { user, error, saveUser } = useUserManager();
user.value.name = 'テストユーザー';
await saveUser();
expect(error.value).toBeNull();
});
it('エラー時に適切にエラーメッセージを設定する', async () => {
// APIエラーをモック
vi.spyOn(global, 'fetch').mockRejectedValue(
new Error('Network Error')
);
const { user, error, saveUser } = useUserManager();
await saveUser();
expect(error.value).toBe('Network Error');
});
});
再利用性の向上
一度作成した composable は、プロジェクト全体で再利用できます。
typescript// 複数のコンポーネントで同じロジックを使用
// UserProfile.vue
const { user, updateUser } = useUserManager();
// UserSettings.vue
const { user, updateUser } = useUserManager();
// AdminPanel.vue
const { user, updateUser, deleteUser } = useUserManager();
composables の作成方法
基本的な composable の構造
composable は、use
で始まる関数名で作成し、関連する状態とロジックをまとめます。
typescript// composables/useCounter.ts
import { ref, computed } from 'vue';
export const useCounter = (initialValue = 0) => {
// 状態の定義
const count = ref(initialValue);
// 計算された値
const isEven = computed(() => count.value % 2 === 0);
const isPositive = computed(() => count.value > 0);
// メソッドの定義
const increment = () => count.value++;
const decrement = () => count.value--;
const reset = () => (count.value = initialValue);
// 戻り値(必要なものだけを公開)
return {
count: readonly(count),
isEven,
isPositive,
increment,
decrement,
reset,
};
};
リアクティブな状態の管理
composable 内での状態管理は、ref
とreactive
を使い分けて行います。
typescript// composables/useForm.ts
import { reactive, ref, computed } from 'vue';
export const useForm = <T extends Record<string, any>>(
initialData: T
) => {
// フォームデータ(オブジェクトなのでreactiveを使用)
const formData = reactive({ ...initialData });
// 単純な値はrefを使用
const isSubmitting = ref(false);
const errors = ref<Record<string, string>>({});
// バリデーション状態
const isValid = computed(() => {
return Object.keys(errors.value).length === 0;
});
// フォームのリセット
const reset = () => {
Object.assign(formData, initialData);
errors.value = {};
isSubmitting.value = false;
};
return {
formData,
isSubmitting,
errors,
isValid,
reset,
};
};
非同期処理の扱い方
API 通信などの非同期処理も、composable 内で適切に管理できます。
typescript// composables/useApi.ts
import { ref } from 'vue';
export const useApi = <T>(url: string) => {
const data = ref<T | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const fetchData = async () => {
loading.value = true;
error.value = null;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`HTTP error! status: ${response.status}`
);
}
data.value = await response.json();
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Unknown error';
console.error('API Error:', err);
} finally {
loading.value = false;
}
};
const updateData = async (payload: Partial<T>) => {
loading.value = true;
error.value = null;
try {
const response = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(
`HTTP error! status: ${response.status}`
);
}
data.value = await response.json();
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Unknown error';
console.error('API Error:', err);
} finally {
loading.value = false;
}
};
return {
data: readonly(data),
loading: readonly(loading),
error: readonly(error),
fetchData,
updateData,
};
};
実践例:ユーザー管理機能
ユーザー情報の取得
実際のプロジェクトでよく使われるユーザー管理機能を、composable として実装してみましょう。
typescript// composables/useUser.ts
import { ref, computed } from 'vue';
interface User {
id: number;
name: string;
email: string;
avatar?: string;
isActive: boolean;
createdAt: string;
}
export const useUser = () => {
const user = ref<User | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// ユーザー情報の取得
const fetchUser = async (userId: number) => {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('ユーザーが見つかりません');
}
throw new Error(
`サーバーエラー: ${response.status}`
);
}
user.value = await response.json();
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Unknown error';
console.error('User fetch error:', err);
} finally {
loading.value = false;
}
};
// 計算された値
const displayName = computed(() => {
return user.value?.name || 'ゲストユーザー';
});
const isNewUser = computed(() => {
if (!user.value) return false;
const createdAt = new Date(user.value.createdAt);
const now = new Date();
const diffDays =
(now.getTime() - createdAt.getTime()) /
(1000 * 60 * 60 * 24);
return diffDays < 7;
});
return {
user: readonly(user),
loading: readonly(loading),
error: readonly(error),
displayName,
isNewUser,
fetchUser,
};
};
ユーザーの更新処理
ユーザー情報の更新処理も、エラーハンドリングを含めて実装します。
typescript// composables/useUserUpdate.ts
import { ref } from 'vue';
interface UpdateUserData {
name?: string;
email?: string;
avatar?: string;
}
export const useUserUpdate = () => {
const updating = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
const updateUser = async (
userId: number,
data: UpdateUserData
) => {
updating.value = true;
error.value = null;
success.value = false;
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({}));
if (response.status === 400) {
throw new Error(
errorData.message ||
'入力データが正しくありません'
);
}
if (response.status === 409) {
throw new Error(
'このメールアドレスは既に使用されています'
);
}
throw new Error(
`更新に失敗しました: ${response.status}`
);
}
success.value = true;
return await response.json();
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Unknown error';
console.error('User update error:', err);
throw err;
} finally {
updating.value = false;
}
};
const reset = () => {
error.value = null;
success.value = false;
};
return {
updating: readonly(updating),
error: readonly(error),
success: readonly(success),
updateUser,
reset,
};
};
エラーハンドリング
実際の開発では、様々なエラーケースに対応する必要があります。
typescript// composables/useErrorHandler.ts
import { ref } from 'vue';
export const useErrorHandler = () => {
const errors = ref<
Array<{
id: string;
message: string;
type: 'error' | 'warning' | 'info';
timestamp: Date;
}>
>([]);
const addError = (
message: string,
type: 'error' | 'warning' | 'info' = 'error'
) => {
const error = {
id: Date.now().toString(),
message,
type,
timestamp: new Date(),
};
errors.value.push(error);
// エラーを自動的に削除(5秒後)
setTimeout(() => {
removeError(error.id);
}, 5000);
};
const removeError = (id: string) => {
const index = errors.value.findIndex(
(error) => error.id === id
);
if (index > -1) {
errors.value.splice(index, 1);
}
};
const clearErrors = () => {
errors.value = [];
};
return {
errors: readonly(errors),
addError,
removeError,
clearErrors,
};
};
実践例:フォーム管理機能
フォームの状態管理
フォームの状態管理は、多くのプロジェクトで共通の課題です。composable を使って効率的に管理しましょう。
typescript// composables/useForm.ts
import { reactive, ref, computed } from 'vue';
interface ValidationRule {
required?: boolean;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
custom?: (value: any) => boolean | string;
}
interface FormConfig<T> {
initialData: T;
validationRules?: Partial<
Record<keyof T, ValidationRule>
>;
}
export const useForm = <T extends Record<string, any>>(
config: FormConfig<T>
) => {
const formData = reactive({ ...config.initialData });
const touched = reactive<Record<keyof T, boolean>>(
{} as Record<keyof T, boolean>
);
const errors = reactive<Record<keyof T, string>>(
{} as Record<keyof T, string>
);
const isSubmitting = ref(false);
// バリデーション関数
const validateField = (
field: keyof T,
value: any
): string => {
const rules = config.validationRules?.[field];
if (!rules) return '';
if (rules.required && !value) {
return 'この項目は必須です';
}
if (rules.minLength && value.length < rules.minLength) {
return `${rules.minLength}文字以上で入力してください`;
}
if (rules.maxLength && value.length > rules.maxLength) {
return `${rules.maxLength}文字以下で入力してください`;
}
if (rules.pattern && !rules.pattern.test(value)) {
return '入力形式が正しくありません';
}
if (rules.custom) {
const result = rules.custom(value);
if (typeof result === 'string') return result;
if (!result) return '入力内容が正しくありません';
}
return '';
};
// フィールドの更新
const updateField = (field: keyof T, value: any) => {
formData[field] = value;
touched[field] = true;
// リアルタイムバリデーション
const error = validateField(field, value);
if (error) {
errors[field] = error;
} else {
delete errors[field];
}
};
// 全体のバリデーション
const validate = (): boolean => {
let isValid = true;
Object.keys(formData).forEach((key) => {
const field = key as keyof T;
const error = validateField(field, formData[field]);
if (error) {
errors[field] = error;
isValid = false;
} else {
delete errors[field];
}
touched[field] = true;
});
return isValid;
};
// フォームのリセット
const reset = () => {
Object.assign(formData, config.initialData);
Object.keys(touched).forEach((key) => {
touched[key as keyof T] = false;
});
Object.keys(errors).forEach((key) => {
delete errors[key as keyof T];
});
isSubmitting.value = false;
};
// 計算された値
const isValid = computed(() => {
return Object.keys(errors).length === 0;
});
const hasErrors = computed(() => {
return Object.keys(errors).length > 0;
});
return {
formData,
touched: readonly(touched),
errors: readonly(errors),
isSubmitting: readonly(isSubmitting),
isValid,
hasErrors,
updateField,
validate,
reset,
};
};
バリデーション処理
具体的なバリデーションルールの実装例です。
typescript// composables/useValidation.ts
export const useValidation = () => {
// メールアドレスのバリデーション
const validateEmail = (email: string): string => {
if (!email) return 'メールアドレスを入力してください';
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
return '正しいメールアドレスを入力してください';
}
return '';
};
// パスワードのバリデーション
const validatePassword = (password: string): string => {
if (!password) return 'パスワードを入力してください';
if (password.length < 8) {
return 'パスワードは8文字以上で入力してください';
}
if (!/(?=.*[a-z])/.test(password)) {
return 'パスワードには小文字を含めてください';
}
if (!/(?=.*[A-Z])/.test(password)) {
return 'パスワードには大文字を含めてください';
}
if (!/(?=.*\d)/.test(password)) {
return 'パスワードには数字を含めてください';
}
return '';
};
// 電話番号のバリデーション
const validatePhone = (phone: string): string => {
if (!phone) return '電話番号を入力してください';
const phonePattern = /^[0-9-+\s()]+$/;
if (!phonePattern.test(phone)) {
return '正しい電話番号を入力してください';
}
const digitsOnly = phone.replace(/[^0-9]/g, '');
if (digitsOnly.length < 10 || digitsOnly.length > 11) {
return '電話番号は10桁または11桁で入力してください';
}
return '';
};
return {
validateEmail,
validatePassword,
validatePhone,
};
};
送信処理
フォームの送信処理も、エラーハンドリングを含めて実装します。
typescript// composables/useFormSubmit.ts
import { ref } from 'vue';
interface SubmitOptions {
onSuccess?: (data: any) => void;
onError?: (error: string) => void;
onFinally?: () => void;
}
export const useFormSubmit = () => {
const isSubmitting = ref(false);
const submitError = ref<string | null>(null);
const submitSuccess = ref(false);
const submitForm = async <T>(
submitFn: () => Promise<T>,
options: SubmitOptions = {}
): Promise<T | null> => {
isSubmitting.value = true;
submitError.value = null;
submitSuccess.value = false;
try {
const result = await submitFn();
submitSuccess.value = true;
options.onSuccess?.(result);
return result;
} catch (err) {
const errorMessage =
err instanceof Error
? err.message
: '送信に失敗しました';
submitError.value = errorMessage;
options.onError?.(errorMessage);
console.error('Form submit error:', err);
return null;
} finally {
isSubmitting.value = false;
options.onFinally?.();
}
};
const reset = () => {
submitError.value = null;
submitSuccess.value = false;
};
return {
isSubmitting: readonly(isSubmitting),
submitError: readonly(submitError),
submitSuccess: readonly(submitSuccess),
submitForm,
reset,
};
};
テスト戦略
composables の単体テスト
composable のテストは、Vue Test Utils と Vitest を使って効率的に行えます。
typescript// tests/composables/useCounter.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useCounter } from '@/composables/useCounter';
describe('useCounter', () => {
let counter: ReturnType<typeof useCounter>;
beforeEach(() => {
counter = useCounter(0);
});
it('初期値が正しく設定される', () => {
expect(counter.count.value).toBe(0);
});
it('incrementで値が1増加する', () => {
counter.increment();
expect(counter.count.value).toBe(1);
});
it('decrementで値が1減少する', () => {
counter.increment();
counter.decrement();
expect(counter.count.value).toBe(0);
});
it('resetで初期値に戻る', () => {
counter.increment();
counter.increment();
counter.reset();
expect(counter.count.value).toBe(0);
});
it('isEvenが正しく計算される', () => {
expect(counter.isEven.value).toBe(true);
counter.increment();
expect(counter.isEven.value).toBe(false);
});
});
モックの活用方法
API 通信を含む composable のテストでは、モックを活用します。
typescript// tests/composables/useUser.test.ts
import {
describe,
it,
expect,
beforeEach,
vi,
} from 'vitest';
import { useUser } from '@/composables/useUser';
// fetchのモック
global.fetch = vi.fn();
describe('useUser', () => {
let userComposable: ReturnType<typeof useUser>;
beforeEach(() => {
userComposable = useUser();
vi.clearAllMocks();
});
it('ユーザー情報を正常に取得できる', async () => {
const mockUser = {
id: 1,
name: 'テストユーザー',
email: 'test@example.com',
isActive: true,
createdAt: '2023-01-01T00:00:00Z',
};
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
await userComposable.fetchUser(1);
expect(userComposable.user.value).toEqual(mockUser);
expect(userComposable.loading.value).toBe(false);
expect(userComposable.error.value).toBeNull();
});
it('404エラーを適切に処理する', async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
status: 404,
});
await userComposable.fetchUser(999);
expect(userComposable.error.value).toBe(
'ユーザーが見つかりません'
);
expect(userComposable.loading.value).toBe(false);
});
it('ネットワークエラーを適切に処理する', async () => {
(fetch as any).mockRejectedValueOnce(
new Error('Network Error')
);
await userComposable.fetchUser(1);
expect(userComposable.error.value).toBe(
'Network Error'
);
expect(userComposable.loading.value).toBe(false);
});
});
テストカバレッジの向上
テストカバレッジを向上させるために、エッジケースも含めてテストします。
typescript// tests/composables/useForm.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useForm } from '@/composables/useForm';
describe('useForm', () => {
const initialData = {
name: '',
email: '',
age: 0,
};
const validationRules = {
name: { required: true, minLength: 2 },
email: {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
},
age: {
required: true,
custom: (value: number) => value >= 0,
},
};
it('初期状態が正しく設定される', () => {
const form = useForm({
initialData,
validationRules,
});
expect(form.formData).toEqual(initialData);
expect(form.isValid.value).toBe(false);
expect(form.hasErrors.value).toBe(false);
});
it('必須フィールドのバリデーションが動作する', () => {
const form = useForm({
initialData,
validationRules,
});
form.validate();
expect(form.errors.name).toBe('この項目は必須です');
expect(form.errors.email).toBe('この項目は必須です');
expect(form.isValid.value).toBe(false);
});
it('正しいデータでバリデーションが通る', () => {
const form = useForm({
initialData,
validationRules,
});
form.updateField('name', 'テストユーザー');
form.updateField('email', 'test@example.com');
form.updateField('age', 25);
expect(form.isValid.value).toBe(true);
expect(form.hasErrors.value).toBe(false);
});
it('フィールド更新時にリアルタイムバリデーションが動作する', () => {
const form = useForm({
initialData,
validationRules,
});
form.updateField('name', 'a'); // minLength未満
expect(form.errors.name).toBe(
'2文字以上で入力してください'
);
expect(form.touched.name).toBe(true);
});
});
まとめ
Vue.js の Composition API を使ったビジネスロジックの分離は、現代のフロントエンド開発において必須のスキルとなっています。
この記事で紹介した composables の活用により、以下のような大きなメリットを得ることができます:
コードの品質向上
- 関連するロジックが一箇所にまとまり、可読性が大幅に向上
- 型安全性の確保により、実行時エラーを事前に防げる
- テスタビリティの向上で、バグの早期発見が可能
開発効率の向上
- 一度作成した composable は、プロジェクト全体で再利用可能
- 新しい機能追加時も、既存の composable を組み合わせて素早く実装
- チーム開発でのコード共有が容易
保守性の向上
- ビジネスロジックの変更が、影響範囲を限定して行える
- バグ修正も、該当する composable のみを修正すれば済む
- コードの責任範囲が明確で、デバッグが容易
実際の開発現場では、この記事で紹介したパターンを参考に、プロジェクトの要件に合わせてカスタマイズしてください。最初は小さな機能から始めて、徐々に複雑なロジックにも適用していくことをお勧めします。
Composition API の真価は、単なる API の違いではなく、コードの設計思想の転換にあります。関数型プログラミングの考え方を取り入れることで、より保守性が高く、拡張しやすいアプリケーションを構築できるようになります。
これから Vue.js プロジェクトを始める方も、既存プロジェクトの改善を検討している方も、ぜひ Composition API と composables の活用を検討してみてください。きっと、開発体験が大きく変わることでしょう。
関連リンク
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来