🦠

Vue3 / Nuxt3における複雑なフォームの状態管理:Composition APIと状態の分離

に公開

1. はじめに

こんにちは!
私はディップ株式会社で生成AIを用いた社内向けプロダクトを開発しています。

弊社では、営業メモや企業情報からAIを活用して求人原稿を生成し、管理するアプリケーションの開発を進めています。詳しくは以下の記事で取り上げて頂いています!

https://dippeople.dip-net.jp/18941/

今回、PoC用に開発した簡易なフロントエンドをVue3/Nuxt3へリプレイスしました。
その際、複雑なフォーム管理を実現するために「Form/State分離パターン」というアーキテクチャを採用しました。
これにより複雑な入力フォームの状態管理が以前に比べて柔軟で保守性の高い実装になりました。
本記事では、このパターンの詳細とVue3のComposition APIがどのようにこのパターンの実現を可能にしたかについて解説します。

ちなみにVue3/Nuxt3という技術選択の理由は、部内の別プロダクトでNuxt3を採用しているためナレッジが溜まっており、またエンジニアメンバーに経験者が居たためです。

前提知識

2. 直面した課題:複雑化するフォームと状態管理

リプレイス前の問題点

バイトルなど弊社の求人媒体は項目数が多く、また求人広告規定上の制約から項目間にやや複雑な依存関係があったりします。そのためフォームの各項目に対する制約 ≒ ドメインロジックの量が大きくなりがちでした。
また本アプリでは求人原稿の登録APIだけでなく、原稿を生成するAPIや最寄り駅を検索するAPIなど複数のAPIとのやり取りを通してフォームの表示を更新する必要がありました。

まとめると以下のような課題がありました:

  1. 複雑なフォーム管理の難しさ

    • 多数の入力項目に関するドメインロジックと状態管理
    • 項目間の依存関係による表示制御
    • バリデーションロジックの複雑化
  2. 複数APIとのやりとり

    • 1回の送信だけでは終了しない
    • 複数のAPIを呼び出して結果をUIに反映させる

目指すべき状態

技術リプレイスにあたり、以下の目標を設定しました:

  • 関心の分離: データモデル、フォームロジック、UIの明確な分離
  • テスト容易性: ロジックの分離によるテストの容易化
  • 保守性の向上: 明確なアーキテクチャによるコードの可読性と保守性の向上

3. 解決策:Form/State分離パターンの導入

アーキテクチャ全体像

状態管理をフォーム状態とAPIリクエスト等の状態に分離してみます。
これをForm/State分離パターンと呼ぶことにします。(AIが名付けてくれました)
フォーム状態管理をForm層、APIリクエスト等の状態管理をState層で行わせます。

各層の役割と責務

1. View(~xxx.vue)

  • Vueコンポーネント
  • UIの表示
  • ユーザー操作の受け付け(Form層のメソッド呼び出し)

2. Form層(~Form.ts)

  • フォームの状態を保持
  • ドメインロジックの適用(ロジック自体は純粋関数として別ファイルに切り出し)
    • フォーム入力値のバリデーション
    • 入力値のサニタイズ
      etc...
  • UIロジックの記述
    • エラーメッセージ
    • 動的に変化するセレクトボックスの選択肢
      etc...
  • State層への値の反映

3. State層(~State.ts)

  • データモデルを定義・状態を保持
  • データモデル特有のロジック
    • APIリクエストパラメータの構築
    • ダウンロードファイルの構築
      etc..

4. その他コンポーザブル

  • 外部APIとの通信
  • ファイルダウンロード処理
  • その他の共通処理の提供
    • 画面のローディング状態管理
    • ログ送信
      etc...

データフロー

APIを呼び出す場合の例を示します。

  1. ユーザーがUIコンポーネントに入力
  2. コンポーネントがForm層のメソッドを呼び出し
  3. Form層でバリデーションと処理を実行
  4. バリデーション成功時のみState層に値を反映
  5. API呼び出しのコンポーザブルからAPIリクエスト
  6. レスポンスに基づいて状態が更新される
[ユーザー入力] → [Form層] → [バリデーション] → [State層] → [API呼び出しコンポーザブル] → [API通信] → [状態更新]

4. Form/State分離パターンを支えるVue3 Composition API

Composition APIの恩恵

Vue3のComposition APIは、Form/State分離パターンの実現に不可欠な要素でした。以下の特徴が特にこのパターンの実現を可能にしました。

1. ロジックの抽出と再利用性

Composition APIでは、関連するロジックや状態を一つの関数にまとめることができます。これをコンポーザブルと呼びます。コンポーザブルはステートフルなロジックをカプセル化したものと言えます。
これにより、View(コンポーネント)からForm層とState層のロジック/状態を明確に分離し、再利用することが可能になりました。

useBlogState.ts
// State層のコンポーザブル
export const useBlogState = () => {
  // データモデル
  const blog = ref({
    title: '',
    isPublished: false
  })

  return {
    blog
  }
}
useBlogForm.ts
// Form層のコンポーザブル
export const useBlogForm = (useBlogState: UseBlogState) => {
  const title = ref('')

  // この関数はUIからのイベントで呼び出される
  const setTitle = (value: string) => {
    // ...処理ロジック
  }

  // ...

  return {
    title,
    setTitle
    // ...
  }
}

2. リアクティビティシステムの柔軟な活用

refreactiveなどのAPIを使用して、より細かくリアクティビティを制御できるようになりました。これにより、Form層とState層で異なる粒度の状態管理が可能になりました。

// Form層での状態管理
const title = ref('')
const titleValidationResult = ref({ valid: true, message: '' })

// ...バリデーション処理

// State層への反映
if (titleValidationResult.value.valid) {
  useBlogState.blog.value.title = title.value
}

3. 型システムとの親和性

TypeScriptとの親和性が高まり、型安全なコードを書きやすくなりました。特に依存関係の型定義が明確になり、開発時のエラー検出が容易になりました。

// 型定義
export type UseBlogState = ReturnType<typeof useBlogState>
export type UsBlogxForm = ReturnType<typeof useBlogForm>

// 型付きの依存注入
export const useBlogForm = (useBlogState: UseBlogState) => {
  // 型安全な実装
}

なぜOptions APIではこのパターンが難しかったのか

Options APIでForm/State分離パターンを実現しようとすると、以下のような課題が生じます:

// Options APIでの実装例
export default {
  data() {
    return {
      // Form層とState層の状態が混在
      title: '',
      titleValidationResult: { valid: true, message: '' },
      blog: {
        title: ''
      }
    }
  },
  methods: {
    // ロジックも混在
    setTitle(value) {
      this.title = value
      this.validateTitle()
      if (this.titleValidationResult.valid) {
        this.blog.title = value
      }
    },
    validateTitle() {
      // バリデーションロジック
    }
  }
}

Options APIでは:

  • ロジックの分離が難しい(データとメソッドが密結合)
  • コードの再利用が困難(ミックスインでは不十分)
  • 型推論が弱い(オブジェクトベースの構造のため)

5. Form/State分離パターンの実践:詳細とメリット

ブログ投稿フォームを例により具体的な実装を見ていきます。

コンポーザブルによる状態管理の詳細

State層での状態管理

State層では、APIリクエストに近い形式でデータを保持します。

useBlogState.ts
export const useBlogState = () => {
  const blog = ref({
    title: '',
    content: '',
    isPublished: false
  })

  // 初期化処理
  const initialize = () => {
    blog.value = {
      title: '',
      content: '',
      isPublished: false
    }
  }

  return {
    blog,
    initialize
  }
}

Form層での状態管理

Form層では、UIの状態を管理するためのリアクティブな変数を定義します。

useBlogForm.ts
export const useBlogForm = (useBlogState: UseBlogState) => {
  /************************************************
   * タイトル
   ************************************************/
  const title = ref('')

  // バリデーション
  const titleValidationResult = computed(() => {
    const rules = [validationRules.isRequired]
    return {
      valid: rules.every(rule => rule(title.value).valid)
        && validationRules.isOverMaxLength(title.value, 15).valid,
      message: [
        ...rules
          .map(rule => rule(title.value).message ?? ''),
        validationRules.isOverMaxLength(title.value, 15).message ?? '',
      ].filter(message => message),
    }
  })

  // 値の設定とStateへの反映
  const setTitle = (value: string) => {
    title.value = value

    if (titleValidationResult.value.valid) {
      useBlogState.blog.value.title = value
    }
  }

  /************************************************
   * 内容
   ************************************************/
  const content = ref('')
  // ...その他のフォーム項目


  /************************************************
   * 全体のバリデーション結果・UI表示
   ************************************************/
  const isFormValid = computed(()=> {
    return titleValidationResult.value.valid
    && contentValidationResult.value.valid
    && // ...その他のフォーム項目
  })

  // ...その他UI表示関連

  /**
   * フォーム全体の状態の初期化
   */
  const initialize = () => {
    title.value = ''
    content.value = ''
    // ...その他のフォーム項目
  }

  return {
    title,
    setTitle,
    // ...その他のフォーム項目
    initialize
  }
}

依存関係の明確化

Form/State分離パターンでは、コンポーザブル間の依存関係が明示的になります。

index.vue
// 依存関係の明示的な定義
const blogState = useBlogState()
provide('useBlogState', blogState)

const blogForm = useBlogForm(blogState) // 引数への設定で依存関係が明確に
provide('useBlogForm', blogForm)
BlogForm.vue
// コンポーネントでの使用例
const useBlogForm = inject<UseBlogForm>('useBlogForm')!

テスト容易性の向上

各層が独立しているため、単体テストが書きやすくなります。

// State層のテスト
describe('useBlogState', () => {
  it('initializeで初期化されること', () => {
    const { blog, initialize } = useBlogState()
    blog.value.title = 'test'
    initialize()
    expect(blog.value.title).toBe('')
  })
})

// Form層のテスト
describe('useBlogForm', () => {
  it('タイトルが空ならバリデーションNGになること', () => {
    const mockState = useBlogState()
    const { setTitle, titleValidationResult } = useBlogForm(mockState)
    setTitle('')
    expect(titleValidationResult.value.valid).toBe(false)
  })
})

具体的なメリット

  1. 関心の明確な分離

    • UI表示、UIの状態とロジック、データモデル、の責務が明確
    • テスタビリティの向上
    • 保守性の向上
  2. 堅牢なバリデーション

    • フォーム入力の検証が明示的
    • クリーンなデータモデルの維持
  3. 柔軟なコンポーネント設計

    • 再利用可能なUIパーツ
    • 一貫性のあるUI構成

応用:複数のStateを管理する

例えばフォームの内容をサーバーに送信するだけでなく、CSVファイルでダウンロードも出来るようにしたいとします。またサーバーに送信するAPIリクエストのパラメータとダウンロードするCSVの項目数や項目のデータ形式が微妙に違います。

このような場合でもForm/State分離パターンでは、State層にCSVダウンロード用のuseBlogCsvState.tsを追加すればAPIリクエストとは状態管理を分離でき、CSV独自のデータ変換のロジックもまとめられます。

基幹DBと実際に業務で使われているCSVなどのファイルでデータ形式が異なるなどの課題はDXの現場でよくある悩みなのではないでしょうか。そのような場面でもForm/State分離パターンの恩恵を受けられると思います。

6. Form/State分離パターンの検討事項

学習コストとボイラープレート

  1. 学習コスト

    • 標準的なVue/Nuxtパターンからの逸脱
    • 新規参入者の理解に時間が必要
  2. ボイラープレートコード

    • Form/State分離による重複コード
    • 小機能でも多くのファイル生成が必要

小規模な機能への適用におけるトレードオフ

小規模な機能や単純なフォームに対しては、このパターンは過剰な場合があります。以下のような判断基準で適用を検討するとよいでしょう:

  • フォームの複雑さ(入力項目数、相互依存関係)
  • バリデーションルールの複雑さ
  • 再利用の必要性
  • チーム開発の規模

7. まとめと今後の展望

リプレイスによる具体的な改善効果

  1. コード品質の向上

    • TypeScriptによる型安全性の向上
    • テスト容易性の向上
    • 保守性の向上
  2. 開発効率の向上

    • 明確なアーキテクチャによるコードの可読性向上
    • 再利用可能なコンポーネントとロジック
    • チーム開発の円滑化

本アーキテクチャがフィットするプロジェクト

このアーキテクチャは、以下のような状況で特に効果を発揮します:

  1. 複雑なフォーム処理を含むアプリケーション

    • 多段階のバリデーションを持つフォーム
    • APIとの複雑なデータマッピングが必要なケース
  2. 厳格なデータ整合性が求められるアプリケーション

    • ユーザー入力と内部モデルの明確な分離が必要
    • 入力検証が複雑なケース
  3. 長期的な保守が予定されるプロジェクト

    • 明確な責任分担による保守性の向上
    • コンポーネントの再利用性を重視

今後の改善

状態管理ライブラリの段階的な導入

  • Piniaなどの状態管理ライブラリの検討
  • グローバル状態の一元管理

このアーキテクチャの採用により、開発効率とコード品質の向上を実現できましたが、さらなる改善の余地はあります。継続的な改善と最適化を行っていく予定です。

Discussion