この記事は SurveyJS によって後援されています ほとんどの React 開発者が、大々的に議論することなく共有しているメンタル モデルがあります。フォームは常にコンポーネントである必要があります。これは次のようなスタックを意味します。

ローカル状態の React Hook Form (最小限の再レンダリング、人間工学に基づいたフィールド登録、命令型インタラクション)。 検証用の Zod (入力の正確性、境界検証、タイプセーフな解析)。 バックエンドの React Query: 送信、再試行、キャッシュ、サーバー同期など。

そして、ログイン画面、設定ページ、CRUD モーダルなど、ほとんどのフォームでは、これは非常にうまく機能します。それぞれの部分がその役割を果たし、きれいに構成されているので、実際に製品を差別化するアプリケーションの部分に進むことができます。 ただし、フォームには、以前の回答に依存する表示ルールや、3 つのフィールドをカスケードする派生値などのものが蓄積され始めることがあります。場合によっては、累計に基づいてスキップまたは表示する必要があるページ全体さえも可能です。 最初の条件は useWatch とインライン分岐で処理しますが、これは問題ありません。それからもう一つ。次に、Zod スキーマでは通常の方法では表現できないクロスフィールド ルールをエンコードするために superRefine を使用します。その後、ステップ ナビゲーションによってビジネス ロジックが漏洩し始めます。ある時点で、自分が構築したものを見て、そのフォームが実際には UI ではなくなっていることに気づきます。これはむしろ意思決定プロセスであり、コンポーネント ツリーはそれをたまたま保存した場所にすぎません。 ここで、React のフォームのメンタル モデルが崩壊していると思いますが、実際には誰のせいでもありません。 RHF + Zod スタックは、その設計目的において優れています。問題は、代替案ではフォームについてまったく異なる考え方が必要になるため、その抽象化が問題に一致する時点を超えてそれを使い続ける傾向があるということです。 この記事ではその代替案について説明します。これを示すために、まったく同じマルチステップ フォームを 2 回作成します。

React Hook Form + Zod を React Query に接続して送信すると、 SurveyJS では、フォームをコンポーネント ツリーではなくデータ (単純な JSON スキーマ) として扱います。

同じ要件、同じ条件ロジック、最後に同じ API 呼び出し。次に、何が移動し、何が残ったかを正確にマッピングし、どのモデルをいつ使用するかを決定するための実用的な方法を示します。 私たちが構築しているフォーム:

このフォームでは、次の 4 ステップのフローを使用します。 ステップ 1: 詳細

名 (必須)、 電子メール (必須、有効な形式)。

ステップ 2: 注文する

単価、 数量、 税率、 派生: 小計、 税金、 合計。

ステップ 3: アカウントとフィードバック

アカウントをお持ちですか? (はい/いいえ) 「はい」の場合 → ユーザー名とパスワードの両方が必要です。 「いいえ」の場合 → 電子メールはステップ 1 ですでに収集されています。

満足度評価 (1 ~ 5) 4 つ以上の場合 → 「何が気に入りましたか?」と尋ねます。 ≤ 2 の場合 → 「何を改善できるか?」と尋ねます。

ステップ 4: 確認する

合計が 100 以上の場合にのみ表示されます 最終提出。

これは極端ではありません。しかし、アーキテクチャの違いを明らかにするには十分です。 パート 1: コンポーネント駆動 (React Hook Form + Zod) インストール npm install 反応フックフォーム zod @hookform/resolvers @tanstack/react-query

ゾッドスキーマ Zod スキーマから始めましょう。通常は Zod スキーマでフォームの形状が確立されます。最初の 2 つのステップ (個人情報と注文入力) では、必要な文字列、最小値を含む数値、列挙型など、すべてが簡単です。興味深い部分は、条件ルールを表現しようとするところから始まります。

「zod」から { z } をインポートします。

import const formSchema = z.object({ firstName: z.string().min(1, "Required"), email: z.string().email("無効な email"), 価格: z.number().min(0), 数量: z.number().min(1), TaxRate: z.number(), hasAccount: z.enum(["Yes", "No"]), ユーザー名: z.string().optional()、パスワード: z.string().optional()、満足度: z.number().min(1).max(5)、positiveFeedback: z.string().optional()、改善フィードバック: z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount === "Yes") { if (!data.username) { ctx.addIssue({ コード: "カスタム", パス: ["ユーザー名"], メッセージ: "必須" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ コード: "カスタム", パス: ["パスワード"], メッセージ: "最小 6 文字" } }

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ code: "custom", path: ["positiveFeedback"], message: "気に入ったものを共有してください" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ コード: "カスタム", パス:["improvementFeedback"]、メッセージ: "改善すべき点を教えてください" }); }});

エクスポートタイプ FormData = z.infer<フォームスキーマのタイプ>;

ユーザー名とパスワードは、条件付きで必須であるにもかかわらず、optional() として入力されていることに注意してください。これは、Zod の型レベルのスキーマがオブジェクトの形状を記述しており、フィールドがいつ重要であるかを管理するルールではないためです。 条件付き要件は、形状が検証されて完全なオブジェクトにアクセスできるようになった後に実行される superRefine 内に存在する必要があります。その分離は欠陥ではありません。これはまさにこのツールの設計目的です。superRefine は、スキーマ構造自体で表現できないクロスフィールド ロジックが使用される場所です。 ここで注目すべき点は、このスキーマが何を表現していないのかということです。ページの概念、どのフィールドがどの時点で表示されるかという概念、およびナビゲーションの概念はありません。それらはすべて別の場所に住むことになります。 フォームコンポーネント

import { useForm, useWatch } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { useMutation } from "@tanstack/react-query";import { useState, useMemo } from "react";import { formSchema, type FormData } from "./schema";

const STEPS = ["詳細", "注文", "アカウント", "レビュー"];

type OrderPayload = FormData & { 小計: 数値;税: 数値;合計: 数値 };

エクスポート関数 RHFM​​ultiStepForm() { const [ステップ, setStep] = useState(0);

const ミューテーション = useMutation({ mutationFn: async (ペイロード: OrderPayload) => { const res = await fetch("/api/orders", { メソッド: "POST"、 ヘッダー: { "Content-Type": "application/json" }, 本文: JSON.stringify(ペイロード)、 }); if (!res.ok) throw new Error("送信に失敗しました"); res.json() を返します。 }、 });

const { register, control, handleSubmit, formState: { エラー }, } = useForm({ リゾルバー: zodResolver(formSchema),defaultValues: { 価格: 0, 数量: 1, TaxRate: 0.1, 満足度: 3, hasAccount: "いいえ", }, }); const 価格 = useWatch({ コントロール, 名前: "価格" }); const 数量 = useWatch({ コントロール, 名前: "数量" }); const TaxRate = useWatch({ コントロール, 名前: "taxRate" }); const hasAccount = useWatch({ control, name: "hasAccount" }); const 満足 = useWatch({ コントロール, 名前: "満足" }); const subtotal = useMemo(() => (価格 ?? 0) * (数量 ?? 1), [価格, 数量]); const Tax = useMemo(() => 小計 * (taxRate ?? 0), [小計, TaxRate]); const total = useMemo(() => 小計 + 税, [小計, 税]); const onSubmit = (データ: FormData) => mutation.mutate({ ...データ、小計、税金、合計 }); const showSubmit = (ステップ === 2 && 合計 < 100) || (ステップ === 3 && 合計 >= 100)

return (

{step === 0 && ( <> )}

{step === 1 && ( <>

小計: {subtotal}
税金: {tax}
合計: {total}
)}

{step === 2 && ( <>

{hasAccount === "はい" && ( <> )}

{満足度 >= 4 && (