選択で項目が変わる申込フォームをどう作るか ― 受託の動的・条件分岐フォーム設計 | GH Media
URLがコピーされました

選択で項目が変わる申込フォームをどう作るか ― 受託の動的・条件分岐フォーム設計

URLがコピーされました
選択で項目が変わる申込フォームをどう作るか ― 受託の動的・条件分岐フォーム設計

「保険の申込で、選んだプランによって聞く質問を変えたい」「求人応募フォームを職種ごとに出し分けたい」「見積もり依頼で、明細の行を利用者が好きなだけ追加できるようにしたい」——こうした相談を、受託の現場で年に何度も受けます。一見すると「条件で表示を切り替えるだけ」に見えるのですが、いざ作り始めると、非表示にした項目の値が送信に混ざる、ステップを戻ると入力が消える、二重送信で同じ申込が二件登録される、といった地味な事故が次々に出てきます。

動的フォームが難しいのは、画面の見た目より「状態」と「検証」が複雑になるからです。本記事では、選択に応じて項目が増減するフォーム、複数ステップに分けたウィザード、行を動的に増やす明細フォームを、React / Next.js(App Router 前提)でどう堅牢に作るかを、実装の勘どころと受託での進め方の両面から整理します。

まず「真実の源」を一つにする ― スキーマ駆動

動的フォームでいちばん壊れやすいのは、検証ルールが画面のあちこちに散らばることです。「この入力は数値で必須」「プランAのときだけ必要」といったルールを、JSX の中や onChange ハンドラに直書きしていくと、条件が増えるほど整合性が取れなくなります。

そこで最初に決めるべきは、入力の形と検証ルールを一箇所のスキーマに集約することです。私たちは zod でスキーマを定義し、画面側の React Hook Form と接続する構成を標準にしています。スキーマが「この申込はどんなデータか」の唯一の定義になり、型もそこから生成できます。

import { z } from "zod";

export const applicationSchema = z
  .object({
    plan: z.enum(["basic", "pro"]),
    email: z.string().email(),
    // proプランのときだけ必須にしたい項目
    companyName: z.string().optional(),
  })
  .refine(
    (v) => v.plan !== "pro" || !!v.companyName,
    { path: ["companyName"], message: "会社名を入力してください" }
  );

export type Application = z.infer<typeof applicationSchema>;

ポイントは、条件付きの必須も refine でスキーマ内に閉じ込めることです。「proのときだけ会社名が要る」というルールが画面ではなくデータ定義側にあるので、後からプランが増えてもルールの置き場所に迷いません。フォーム最適化そのものの発注観点は EFO(フォーム最適化)で本当に効くこと でも触れていますが、技術的な堅牢さの土台は、まずこのスキーマ一元化にあります。

クライアント検証は「親切」、サーバ検証は「防御」

ここで強調したいのは、クライアント側の検証だけに頼ってはいけないことです。ブラウザ上の検証は、利用者が送信前に間違いに気づけるようにするための「親切」であって、セキュリティ境界ではありません。開発者ツールを開けば、非表示にしたはずの項目に値を入れて送ることも、検証を回避して送信することもできます。

だからこそ、同じ zod スキーマをサーバ側でもう一度通す構成にします。App Router の Server Action や Route Handler で、受け取ったデータを safeParse で再検証してから初めて保存処理に進みます。

"use server";
import { applicationSchema } from "@/schemas/application";

export async function submitApplication(formData: unknown) {
  const parsed = applicationSchema.safeParse(formData);
  if (!parsed.success) {
    return { ok: false, errors: parsed.error.flatten() };
  }
  // ここまで来たデータだけを信頼して保存する
  await saveApplication(parsed.data);
  return { ok: true };
}

スキーマを一つに保っておくと、この二重化が「コピペ」ではなく「同じ定義の再利用」で済みます。クライアントとサーバで検証ルールがずれて、片方では通るのにもう片方で弾かれる、という典型的なバグも防げます。

条件付きフィールドと「隠した項目の値」

選択に応じて項目を出し分けるとき、見落としがちなのが非表示フィールドの値をどう扱うかです。プランをproからbasicに切り替えたとき、入力済みの会社名が裏で残っていると、送信データに不要な値が紛れ込みます。場合によっては「basicなのに会社名が入っている」状態でサーバ検証を通ってしまい、データの意味が壊れます。

対処は方針を一つに決めることです。私たちは原則として、条件から外れたフィールドは表示を消すだけでなく値もリセットする運用にしています。React Hook Form なら、監視している値が変わったタイミングで setValueresetField を呼び、非表示になった項目を初期化します。そのうえでサーバ側の refine が「basicのときに会社名があってはならない」まで含めて検証すれば、画面操作の順序に関わらずデータの整合性が保てます。

表示制御そのものは条件分岐で素直に書けますが、アクセシビリティの観点で一点だけ注意があります。項目を出し入れするときは、hidden 属性やマウントの切り替えで支援技術にも確実に存在しない状態を伝えることです。CSS で見えなくしているだけだと、スクリーンリーダーには読み上げられ、利用者は存在しない項目を探して混乱します。この種の配慮は アクセシブルなフォームとナビゲーションの作り方 で詳しく扱っています。

マルチステップで状態を失わない

ステップを分けるウィザード形式は、一画面の情報量を減らして離脱を抑える定番の手法です。ただし「分ける」こと自体が新しい難所を生みます。

  • ステップを戻ったときに入力が消えないか
  • ブラウザの戻るボタンを押すと、フォームの前のステップに戻るのか、サイトから出てしまうのか
  • 途中でリロードしたとき、最初からやり直しになるのか

ここで決めるべきは、全ステップ分の状態を一つのフォームインスタンスで保持し、ステップは「表示する範囲」だけを切り替える設計にするかどうかです。React Hook Form で単一のフォームを作り、現在のステップに応じて表示する <section> を出し分ければ、ステップ間移動で値は消えません。各ステップの「次へ」では、そのステップに属する項目だけを trigger で部分検証してから進めます。

戻る対応とリロード耐性は要件次第です。URL にステップ番号を持たせて(?step=2 のように)ブラウザ履歴と同期させると、戻るボタンが自然に効くようになります。リロードで消したくない長いフォームでは、入力途中の値を sessionStorage に退避して復元します。ただしこれは入力内容を一時的に端末に保存することになるため、扱う情報の機微さに応じて、保存する項目を絞るか暗号化を検討します。同意や保存の扱いを利用者にとって誠実に設計する観点は ダークパターンを避けた同意・解約の設計 にも通じます。

行を動的に増やす明細フォーム

見積もりや経費精算のように、利用者が項目を何行でも追加するフォームでは、配列の状態管理が要になります。React Hook Form の useFieldArray のような仕組みを使うと、行の追加・削除・並べ替えを、各行の入力値を保持したまま安全に扱えます。

実装で気をつけるのは次の三点です。第一に、行ごとに安定したキーを使うこと。配列のインデックスをそのまま key にすると、途中の行を削除したときに React が別の行と取り違えて、入力値が一行ずれて表示される事故が起きます。第二に、検証を配列レベルでも書くこと。zod なら z.array(rowSchema).min(1) のように「最低一行」「各行の必須」を定義し、合計金額のような行をまたぐルールは refine で表現します。第三に、アクセシビリティです。追加ボタンで行が増えたら、新しい行の最初の入力にフォーカスを移すと、キーボードだけで操作する利用者がスムーズに続けられます。エラー表示も、どの行のどの項目かが支援技術に伝わるよう、ラベルとエラーメッセージを aria-describedby で結び付けます。

送信の冪等性 ― 二重登録を防ぐ

最後に、受託で必ず詰めておくべきなのが送信の冪等性です。通信が遅いと、利用者は送信ボタンを何度も押します。ネットワークが不安定なら、同じリクエストが再送されることもあります。何も対策しないと、同じ申込が二件、三件と登録されます。

クライアント側では、送信中はボタンを無効化し、isSubmitting の状態でローディングを明示します。ただしこれも親切の範囲なので、サーバ側に最後の砦を置きます。フォーム表示時に一意なトークンを発行してリクエストに含め、サーバはそのトークンで「この申込はすでに処理済みか」を判定します。処理済みなら新規登録せず、同じ成功レスポンスを返す。こうすると、利用者が二度押ししても結果は一度分にしかなりません。

受託での進め方と工数感

先日担当した、ある人材会社(仮にB社とします)の見積もり申込フォームでは、当初「プラン選択で質問が変わるだけ」という想定で見積もりの相談を受けました。ですが要件を分解すると、プランは三種類でそれぞれ必須項目が異なり、申込明細は行追加が必要で、入力途中の離脱が多いため三ステップのウィザードにしたい、という三つの動的要素が同居していました。

このとき工数が膨らむのは、画面の数ではなく「検証と状態の組み合わせ」です。条件分岐とマルチステップと動的配列が掛け合わさると、テストすべきパターンが急増します。私たちは見積もりの段階で、スキーマ定義・条件分岐・状態保持・サーバ検証・冪等性をそれぞれ独立した作業として切り出し、どこまでを今回作り込むかを発注側と握ります。「戻るボタン対応は要るか」「途中保存は必要か」を最初に決めるだけで、工数は大きく変わります。技術選定の段で「そもそもこのフォームにフルのフレームワークが要るか」を迷う場合は、Next.js と Astro の使い分け も判断材料になります。

動的フォームは、動いているように見えても、隠れた項目・戻る操作・二重送信といった「裏側」で静かに壊れます。選択で項目が変わる申込フォームや、離脱を減らすステップ分割、明細の動的追加を検討していて、堅牢に作りたい・既存のフォームの取りこぼしを直したいという場合は、お問い合わせからご相談ください。要件の分解と工数の見立てからお手伝いします。

Sources

URLがコピーされました

グリームハブ株式会社は、変化の激しい時代において、アイデアを形にし、人がもっと自由に、もっと創造的に生きられる世界を目指しています。

記事を書いた人

鈴木 翔

鈴木 翔

技術の可能性に魅了され、学生時代からプログラミングとデジタルアートの分野に深い関心を持つ

関連記事