なぜフォーム設計パターンを考慮する必要があるのか

React開発者であれば、react-hook-form + Zod + React Queryの組み合わせがフォーム実装の「標準」のように感じられることがあるでしょう。ログイン、設定ページ、基本的なCRUDモーダルには確かに適しています。各ライブラリが明確な役割を持ち、組み合わせもきれいです。

しかし、フォームが次第に複雑になり始めると話が変わります。以前の回答に応じてフィールドが表示・非表示になる条件付き表示ルール、複数のフィールドにまたがる派生値の計算、特定の金額以上の場合のみ表示される確認ページなどが追加されると、コンポーネントツリー内にビジネスロジックが染み込み始めます。

この時点で、フォームはもはや単純なUIではありません。一つの「意思決定プロセス」になります。しかし、私たちは依然としてUIコンポーネントという枠組みの中にこのロジックを詰め込んでいます。この記事では、このような複雑なフォームを実装する二つの根本的に異なるアプローチを比較し、いつどの方法を選択すべきかについて実践的な基準を提示します。この分析はSmashing Magazineのフォーム設計に関する記事を根拠としています。

React and Next.js code editor showing dynamic form components Software Concept Art

事例分析:同一のマルチステップフォーム、二つの実装

比較のために、4ステップからなる注文フォームを想定してみましょう。

  1. 個人情報: 氏名、メールアドレス(必須)
  2. 注文情報: 単価、数量、税率 → 派生値: 小計、税額、合計額の自動計算
  3. アカウント & フィードバック:
    • アカウント所持有無(はい/いいえ) → 「はい」の場合ユーザー名/パスワード必須
    • 満足度(1-5点) → 4点以上の場合肯定的フィードバック、2点以下の場合改善点フィードバックを要求
  4. 確認: 合計額が100以上の場合のみ表示

実装1: コンポーネント主導(React Hook Form + Zod)

Zodスキーマから定義します。条件付き必須項目(ユーザー名/パスワード)はsuperRefineで処理する必要があります。

// schema.ts
import { z } from "zod";

export const formSchema = z.object({
  firstName: z.string().min(1, "必須項目です"),
  email: z.string().email("有効なメールアドレスを入力してください"),
  price: z.number().min(0),
  quantity: z.number().min(1),
  taxRate: z.number(),
  hasAccount: z.enum(["Yes", "No"]),
  // 条件付き必須だが型はoptional
  username: z.string().optional(),
  password: z.string().optional(),
  satisfaction: z.number().min(1).max(5),
  positiveFeedback: z.string().optional(),
  improvementFeedback: z.string().optional(),
}).superRefine((data, ctx) => {
  // 条件付きバリデーションロジック
  if (data.hasAccount === "Yes") {
    if (!data.username) {
      ctx.addIssue({ code: "custom", path: ["username"], message: "必須項目です" });
    }
    if (!data.password || data.password.length < 6) {
      ctx.addIssue({ code: "custom", path: ["password"], message: "6文字以上入力してください" });
    }
  }
  if (data.satisfaction >= 4 && !data.positiveFeedback) {
    ctx.addIssue({ code: "custom", path: ["positiveFeedback"], message: "良かった点を共有してください" });
  }
  if (data.satisfaction <= 2 && !data.improvementFeedback) {
    ctx.addIssue({ code: "custom", path: ["improvementFeedback"], message: "改善点を教えてください" });
  }
});

フォームコンポーネントでは、useWatchでリアルタイム値の追跡、useMemoで派生値の計算、JSX条件文でフィールド表示制御を行います。確認ページ表示条件(total >= 100)はshowSubmit変数とレンダリング条件文に分散しています。

限界: ロジックがコンポーネント(表示制御)、スキーマ(バリデーション)、状態(ナビゲーション)に分散しているため、全体的な振る舞いを一目で把握することが困難です。些細なルール変更でもコード修正と再デプロイが必要になります。

Architecture diagram comparing component-driven vs schema-driven forms Algorithm Concept Visual

実装2: スキーマ主導(SurveyJS)

同一のフォームをJSONスキーマで定義すると、全く異なる様相になります。

// survey-schema.json
{
  "title": "注文フロー",
  "pages": [
    {
      "name": "details",
      "elements": [
        { "type": "text", "name": "firstName", "isRequired": true },
        { "type": "text", "name": "email", "inputType": "email", "isRequired": true }
      ]
    },
    {
      "name": "order",
      "elements": [
        { "type": "text", "name": "price", "inputType": "number", "defaultValue": 0 },
        { "type": "text", "name": "quantity", "inputType": "number", "defaultValue": 1 },
        { "type": "dropdown", "name": "taxRate", "defaultValue": 0.1 },
        { "type": "expression", "name": "subtotal", "expression": "{price} * {quantity}" },
        { "type": "expression", "name": "tax", "expression": "{subtotal} * {taxRate}" },
        { "type": "expression", "name": "total", "expression": "{subtotal} + {tax}" }
      ]
    },
    {
      "name": "account",
      "elements": [
        { "type": "radiogroup", "name": "hasAccount", "choices": ["Yes", "No"] },
        { 
          "type": "text", 
          "name": "username", 
          "visibleIf": "{hasAccount} = 'Yes'",
          "isRequired": true 
        },
        { 
          "type": "text", 
          "name": "password", 
          "inputType": "password",
          "visibleIf": "{hasAccount} = 'Yes'",
          "isRequired": true
        },
        { "type": "rating", "name": "satisfaction", "rateMin": 1, "rateMax": 5 },
        { "type": "comment", "name": "positiveFeedback", "visibleIf": "{satisfaction} >= 4" },
        { "type": "comment", "name": "improvementFeedback", "visibleIf": "{satisfaction} <= 2" }
      ]
    },
    {
      "name": "review",
      "visibleIf": "{total} >= 100",
      "elements": []
    }
  ]
}

違いのまとめ

関心事RHF + Zod スタックSurveyJS(スキーマ)
表示制御JSX条件文visibleIf属性
派生値useWatch + useMemoexpressionタイプフィールド
クロスフィールドルールsuperRefineスキーマ内条件式
ナビゲーションstep状態管理ページvisibleIf
ルールの場所ファイル全体に分散スキーマ内に集中

Reactコンポーネントは、レイアウト、スタイリング、送信API接続のみを担当するようになります。すべてのビジネスロジックはJSONスキーマ内に明示的に定義されます。

日本の開発環境における適用文脈: 日本のSIプロジェクトや大規模エンタープライズシステムでは、フォームのビジネスルールが頻繁に変更されるケースが多く見られます。法規制改正、内部ポリシー変更などにより、プランナーや運用チームが直接ルールを修正する必要がある場合、スキーマ主導方式がより現実的な解決策となる可能性があります。JSONスキーマをDBに保存したりCMSで管理したりすれば、再デプロイなしでリアルタイム適用が可能だからです。

JSON schema data structure visualized next to a web form interface System Abstract Visual

実務選択ガイド:どのアプローチを使用すべきか

簡単な基準を一つ提示します。**「このフォームを完全に削除すると、何を失うか?」**考えてみてください。

  • 画面(UI)を失う → コンポーネント主導フォームが適しています。
  • ビジネスロジック(ルール、条件、意思決定フロー)を失う → スキーマエンジンがより良い選択です。

React Hook Form + Zodを選択する場合

  • フォームが基本的なCRUDに近く、ロジックが単純な場合
  • すべてのビジネスルールを開発者が所有・管理する場合
  • UI変更が主で、バックエンドが依然として真実の源である場合
  • 国内スタートアップや小規模プロジェクトのように迅速なプロトタイピングが重要な場合

SurveyJS(または類似のスキーマエンジン)を選択する場合

  • フォームが複雑なビジネス意思決定をエンコードする場合
  • ルールがUIと独立して進化する必要がある場合(例:プランナー/法務チームの要件)
  • ロジックを可視化、監査、バージョン管理する必要がある場合
  • 同一フォームを複数のフロントエンド(React, Angular, Vue)で再利用する必要がある場合
  • 大規模エンタープライズや金融、医療など規制が厳しい分野で

注意点と次の学習方向

スキーマ主導方式の限界: 学習曲線があります。新しい構文(visibleIfexpression)を習得する必要があります。また、非常に特殊なカスタムUIコンポーネントが必要な場合、スキーマエンジンの拡張性に制約を感じる可能性があります。コンポーネント主導方式の限界は前述の通り、ロジックの分散と変更の困難さです。

合わせて読むと良い記事: この記事で取り上げた「宣言的スキーマ」アプローチは、"クラウド接続が切断されても安全にAIを稼働させる方法 Microsoft Sovereign Cloud アップデート核心"で考察されたオフラインAIの自律性概念や、"Google AI Edgeのオンデバイス関数呼び出し、今やiPhoneでも可能になった"で議論されたエッジコンピューティングのローカル実行パラダイムと通じるものがあります。いずれも「中央集権的制御」から「ローカル自律性」へのパラダイム転換を示しています。

次のステップ: Formik、Final Formなど他のフォームライブラリとの比較、またはJSONスキーマを直接パースしてレンダリングするカスタムエンジンの構築に挑戦してみることも良い学習となるでしょう。複雑さと柔軟性の間で、チームとプロジェクトに最も適したバランスポイントを見つけることが核心です。

本コンテンツは、信頼性の高い情報源をもとにAIツールを活用して作成され、編集者によるレビューを経て公開されています。専門家によるアドバイスの代替となるものではありません。