T-CREATOR

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

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

Vue.js の Composition API を使ったビジネスロジックの分離について、実際の開発現場で役立つ方法をご紹介します。

多くの開発者が直面する「コードが複雑になって管理しづらい」「同じ処理を何度も書いている」「テストが書きにくい」といった課題を、Composition API の composables を使うことで解決できます。この記事では、実際のプロジェクトで使える実践的なテクニックを、エラーケースも含めて詳しく解説していきます。

Composition API の基本概念

リアクティブな状態管理

Composition API の最大の特徴は、リアクティブな状態管理の柔軟性です。従来の Options API では、datacomputedmethodsが分離されていましたが、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 内での状態管理は、refreactiveを使い分けて行います。

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 の活用を検討してみてください。きっと、開発体験が大きく変わることでしょう。

関連リンク