この記事は 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 & { 小計: 数値;税: 数値;合計: 数値 };
エクスポート関数 RHFMultiStepForm() { 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
return (
);}sixextinction による Pen SurveyJS-03-RHF [フォーク] を参照してください。 ここでは非常に多くのことが起こっており、物事がどこに行き着いたのかを理解するために速度を落とす価値があります。
派生値 (小計、税金、合計) は、useWatch および useMemo を介してコンポーネント内で計算されます。これは、これらの値はライブ フィールドの値に依存しており、他に自然な場所がないためです。 ユーザー名、パスワード、positiveFeedback、および ImprovementFeedback の可視性ルールは、インライン条件として JSX に存在します。 ステップスキップ ロジック (合計が 100 以上の場合にのみレビュー ページが表示される) は、showSubmit 変数とステップ 3 のレンダリング条件に埋め込まれています。 ナビゲーション自体は、手動でインクリメントする useState カウンターにすぎません。 React Query は再試行、キャッシュ、無効化を処理します。フォームは検証されたデータを使用して mutation.mutate を呼び出すだけです。
これ自体は何も間違っていません。これは依然として慣用的な React であり、RHF が再レンダリングを分離する方法のおかげで、このコンポーネントは非常にパフォーマンスが高くなります。 しかし、これを書いたことのない人にこれを渡し、レビュー ページがどのような条件で表示されるかを説明してもらうとすると、showSubmit、ステップ 3 のレンダリング条件、ナビゲーション ボタンのロジック (3 つの別々の場所) をたどって、1 行で記述できたはずのルールを再構築する必要があります。 確かにフォームは機能しますが、その動作はシステムとして実際には検査できません。それは精神的に実行されなければなりません。 さらに重要なのは、これを変更するにはエンジニアリングの関与が必要です。レビューステップが表示されるタイミングを調整するなどの小さな調整であっても、コンポーネントの編集、検証の更新、プルリクエストのオープン、レビューを待って、再度デプロイすることを意味します。 パート 2: スキーマ駆動 (SurveyJS) 次に、スキーマを使用して同じフローを構築しましょう。 インストール npm install Survey-core Survey-react-ui @tanstack/react-query
Survey-coreMIT ライセンスのプラットフォームに依存しないランタイム エンジン。SurveyJS のフォーム レンダリングを強化します。ここで注目する部分です。 JSON スキーマを取得し、そこから内部モデルを構築し、可視性の式の評価、派生値の計算、ページの状態の管理、検証の追跡、実際に表示されたページに応じた「完了」の意味の決定など、React コンポーネント内に存在するはずのすべての処理を処理します。
Survey-react-uiそのモデルを React に接続する UI / レンダリング レイヤー。これは本質的に、エンジンの状態が変化するたびに再レンダリングされる
これらを組み合わせることで、制御フローを 1 行も記述することなく、完全に機能する複数ページのフォーム ランタイムが提供されます。 前に述べたように、スキーマ形式自体は単なる JSON であり、DSL など独自のものはありません。インライン化したり、ファイルからインポートしたり、API からフェッチしたり、データベース列に保存して実行時にハイドレートしたりすることができます。 データとして同じ形式 これは同じフォームですが、今回は JSON オブジェクトとして表現されています。スキーマは、構造、検証、可視性ルール、派生計算、ページ ナビゲーションなどすべてを定義し、実行時に評価するモデルに渡します。完全には次のようになります。
import const SurveySchema = { title: "注文フロー", showProgressBar: "top", pages: [ { name: "details", elements: [ { type: "text", name: "firstName", isRequired: true }, { type: "text", name: "email", inputType: "email", isRequired: true, validators: [{ type: "email", text: "無効な電子メール" }] } ] }, { 名前: "注文", 要素: [ { タイプ: "テキスト", 名前: "価格", inputType: "数値", デフォルト値: 0 }, { タイプ: "テキスト", 名前: "数量", inputType: "数値", デフォルト値: 1 }, { タイプ: "ドロップダウン",名前: "taxRate"、defaultValue: 0.1、選択肢: [ { 値: 0.05、テキスト: "5%" }、{ 値: 0.1、テキスト: "10%" }、{ 値: 0.15、テキスト: "15%" } ] }、{ タイプ: "式"、名前: "小計"、式: "{価格} {数量}" }、{ タイプ: "式"、名前: "税金", 式: "{subtotal} {taxRate}" }, { type: "expression", 名前: "total", 式: "{subtotal} + {tax}" } ] }, { name: "account", 要素: [ { type: "radiogroup", name: "hasAccount", 選択肢: ["Yes", "No"] }, { type: "text", name: "username",visibleIf: "{hasAccount} = 'Yes'", isRequired: true }, { type: "text", name: "password", inputType: "password",visibleIf: "{hasAccount} = 'Yes'", isRequired: true, validators: [{ type: "text", minLength: 6, text: "最小 6 文字" }] }, { type: "評価", 名前: "満足度", rateMin: 1、rateMax: 5 }、{ type: "comment"、name: "positiveFeedback"、visibleIf: "{satisfaction} >= 4" }、{ type: "comment"、name: "improvementFeedback"、visibleIf: "{satisfaction} <= 2" } ] }、{ name: "review"、visibleIf: "{total} >= 100"、要素: [] } ]};
これを RHF バージョンと少し比較してください。
条件付きでユーザー名とパスワードを要求する superRefine ブロックはなくなりました。 visibleIf: "{hasAccount} = 'Yes'" と isRequired: true を組み合わせると、両方の懸念事項がフィールド自体で、予想される場所で一緒に処理されます。 小計、税金、合計を計算する useWatch + useMemo チェーンは、名前で相互参照する 3 つの式フィールドに置き換えられます。 レビュー ページの条件。RHF バージョンでは、ステップ 3 のレンダリング ブランチである showSubmit をトレースすることによってのみ再構築可能でした。 最後に、ナビゲーション ボタンのロジックは、ページ オブジェクトの単一のvisibleIf プロパティです。
同じロジックがあります。スキーマによって、コンポーネント全体に分散するのではなく、単独で表示される場所がスキーマに与えられるだけです。 また、スキーマでは小計、税金、合計に type: 'expression' が使用されることに注意してください。式は読み取り専用で、主に計算値を表示するために使用されます。 SurveyJS は、静的コンテンツの type: 'html' もサポートしていますが、計算値の場合は、expression が正しい選択です。 次に React 側について説明します。 レンダリングと送信 とてもシンプルです。 useMutation またはプレーンフェッチを介して、同じ方法で onComplete を API に接続します。
import { useState, useEffect, useRef } from "react";import { useMutation } from "@tanstack/react-query";import { Model } from "survey-core";import { Survey } from "survey-react-ui";import "survey-core/survey-core.css";
エクスポート関数 SurveyForm() { const [モデル] = useState(() => new Model(surveySchema));
const ミューテーション = useMutation({ mutationFn: 非同期 (データ) => { const res = await fetch("/api/orders", { メソッド: "POST"、 ヘッダー: { "Content-Type": "application/json" }, 本体: JSON.stringify(データ)、 }); if (!res.ok) throw new Error("送信に失敗しました"); res.json() を返します。 }、 });
const mutationRef = useRef(変異); mutationRef.current = 突然変異; useEffect(() => { const handler = (sender) => mutationRef.current.mutate(sender.data); model.onComplete.add(handler); return () => model.onComplete.remove(handler); }, [モデル]); // ref は、レンダリングごとにハンドラーを再登録することを回避します (ミューテーション オブジェクトの ID が変更される)
戻る (
<>
sixextinction による Pen SurveyJS-03-SurveyJS [フォーク] を参照してください。
onComplete は、ユーザーが表示されている最後のページの最後に到達したときに起動されます。したがって、合計が 100 を超えず、レビュー ページがスキップされた場合でも、SurveyJS は「最後のページ」の意味を決定する前に可視性を評価するため、レビュー ページは引き続き正しく起動されます。 次に、sender.data には、すべての回答と計算値 (小計、税金、合計) がファーストクラス フィールドとして含まれるため、API ペイロードは、RHF バージョンが onSubmit で手動で組み立てたものと同じになります。 のmutationRef パターンは、レンダリングのたびに変化する値に対する安定したイベント ハンドラーが必要な場合に利用できるものと同じです。これについては SurveyJS 固有のものではありません。
React コンポーネントにはビジネス ロジックがまったく含まれなくなりました。 useWatch、条件付き JSX、ステップ カウンター、useMemo チェーン、superRefine はありません。 React は、コンポーネントをレンダリングして API 呼び出しに接続するという、実際に得意なことを実行しています。 React から何が移行したのでしょうか?
懸念 RHFスタック サーベイJS 可視性 JSX ブランチ 可視の場合 派生値 useWatch / useMemo 表現 クロスフィールドルール スーパーリファイン スキーマ条件 ナビゲーション ステップ状態 ページが表示される場合 ルールの場所 ファイル間で分散 スキーマ内で一元化
React に残るのは、レイアウト、スタイル、送信の配線、アプリの統合、つまり React が実際に設計されているものです。 他のすべてはスキーマに移動され、スキーマは単なる JSON オブジェクトであるため、データベースに保存したり、アプリケーション コードとは独立してバージョン管理したり、デプロイを必要とせずに内部ツールを通じて編集したりできます。 レビュー ページをトリガーするしきい値を変更する必要がある製品マネージャーは、コンポーネントに触れることなく変更を行うことができます。これは、フォームの動作が頻繁に進化し、必ずしもエンジニアによって推進されるわけではないチームにとって、運用上の重要な違いです。 それぞれのアプローチをいつ使用するか? 私にとって有効な経験則は次のとおりです。フォームを完全に削除することを想像してください。何を失うでしょうか?
画面の場合は、コンポーネント駆動型のフォームが必要です。 実際の意思決定をエンコードするしきい値、分岐ルール、条件付き要件などのビジネス ロジックの場合は、スキーマ エンジンが必要になります。
同様に、今後の変更が主にラベル、フィールド、レイアウトに関するものであれば、RHF が適切に機能します。運用チームや法務チームがチケットを提出せずに火曜日の午後に調整する必要がある可能性のある条件、結果、ルールに関するものである場合は、SurveyJS を使用したスキーマ モデルがより正確に適合します。 これら 2 つのアプローチは実際には互いに競合しません。これらはさまざまな種類の問題に対処しており、避けるべき間違いは、抽象化とロジックの重みの不一致です。つまり、使い慣れたツールであるためルール システムをコンポーネントのように扱ったり、フォームが 3 つのステップに成長し、条件付きフィールドを取得したためにポリシー エンジンに手を伸ばしたりすることです。 ここで構築したフォームは意図的に境界近くに配置されており、違いが明らかになるほど複雑ではありますが、比較が不正に操作されていると感じるほど極端ではありません。コードベース内で扱いにくくなっている実際の形式のほとんどは、おそらく同じ境界付近に位置しており、問題は通常、その実際の形式に誰かが名前を付けているかどうかだけです。 次の場合に React Hook Form + Zod を使用します。
フォームは CRUD 指向です。 ロジックは浅く、UI 主導型です。 エンジニアはすべての行動を所有します。 バックエンドは依然として真実の情報源です。
次の場合に SurveyJS を使用します。
フォームはビジネス上の意思決定をエンコードします。 ルールは UI とは独立して進化します。 ロジックは表示、監査可能、またはバージョン管理されている必要があります。 非エンジニアは行動に影響を与えます。 同じフォームは複数のフロントエンドで実行する必要があります。