こんにちは。エンジニアの吉田です。
本記事では、私が開発を担当した「ポケットサインフォーム」において実装した、React Hook Form の context 機能を使った動的なバリデーションスキーマの切り替え方法を紹介します。
背景
ポケットサインフォームは、アンケートや申請フォームを簡単に構築できるサービスです。企業や自治体が用途に応じたフォームを作成し、回答を収集・管理することができます。
このサービスのコンソール画面では、フォーム作成時に「公開」と「下書き保存」の 2 つの保存方法を提供しています。
ポケットサインフォームのフォーム作成画面。右上の”公開する”ボタンと”一時保存する”ボタンからそれぞれ公開と下書き保存が可能になっています。
以前は両保存方法で同じバリデーションスキーマを使用していたため、「公開可能な内容でないと下書き保存できない」という使いづらさがありました。
具体的には、以下のような問題が発生していました:
- タイトルが未定でも下書き保存したいが、タイトルが必須項目になっているため下書き保存できない
- フォーム項目が 1 つもない状態で下書き保存できないため、締切日などの基本設定だけ先に下書き保存するようなことができない
- ラジオボタン項目や選択式項目で、選択肢が空欄だと下書き保存できないため、全体の設問構成だけ下書き保存したくても項目詳細を入力しないと下書き保存できない
そこで、公開時と下書き保存時で異なるバリデーションスキーマを使用できるよう改善しました。
React Hook Form の基本
ポケットサインフォームのフロントエンドでは、フォーム入力状態の管理とバリデーションに React Hook Form を採用しています。React Hook Form は、フォーム管理の定番として広く採用されているライブラリの一つです(*1)。特に重宝するのが Zod や Valibot といったスキーマベースのバリデーションライブラリとの連携機能で、煩雑になりがちなバリデーションロジックやエラー表示を宣言的に記述することが可能です。
React Hook Form - performant, flexible and extensible form library
useFormのresolverオプションでスキーマを指定することで、submit 時にこのスキーマによるバリデーションが自動的に実行され、エラーがあれば formState.errors に格納されます。以下の例では Zod を使用してスキーマを定義し、useFormのresolverオプションでそれを指定しています。
import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; const postSchema = z.object({ title: z.string().min(1, "タイトルは必須です"), content: z.string().min(10, "本文は10文字以上で入力してください"), }); type FormInput = z.infer<typeof postSchema>; export default function App() { const { register, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(postSchema), // バリデーションルールをスキーマで指定 }); const onSubmit = (data: FormInput) => { console.log(data); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register("title")} /> {/* バリデーションエラーがあればエラー文面が表示される */} <p>{errors.title?.message}</p> <input {...register("content")} /> <p>{errors.content?.message}</p> <button type="submit">送信</button> </form> ); }
前述した通り、ポケットサインフォームでは以前まで、このresolverオプションで公開時用のバリデーションスキーマ 1 つのみを指定し、公開ボタンと下書き保存ボタンそれぞれの押下時に実行されるhandleSubmitのコールバック関数のみを変更していました。
const handleSubmitSave = (data: FormOutput) => { const req = { ... } // dataを下書き保存リクエストに整形 const res = await saveFormAsDraft(req) }; const handleSubmitPublish = (data: FormOutput) => { const req = { ... } // dataを公開リクエストに整形 const res = await publishForm(req) }; // ... <button type="button" onClick={handleSubmit(handleSubmitSave)}>下書き保存</button> <button type="button" onClick={handleSubmit(handleSubmitPublish)}>公開</button>
この方法では、同一のフォームコンポーネントを使いながら 公開/下書き保存 それぞれが可能ですが、どちらのボタンを押してもバリデーションには共通のスキーマが使われてしまいます。
そこで、React Hook Form の context 機能を使った動的なバリデーションスキーマの切り替えを実装しました。
context を使った解決法
React Hook Form では、useFormの設定オプションとしてcontextオブジェクトを受け取ることができます。
この contextは、バリデーション実行時にresolver関数の第 2 引数として渡されます。
const methods = useForm({ context: { mode: modeRef }, // contextオブジェクト resolver: async (values, context, options) => { // contextを元にバリデーション処理を分岐 const currentMode = context?.mode?.current ?? "draft"; // バリデーションロジック... }, });
この context を活用することで、フォーム外部の状態に応じてバリデーションスキーマを動的に切り替えることが可能になります。なお、この方法は React Hook Form の GitHub discussion で紹介されている実装パターンを参考にしています。
以下は実際の実装例です。useRefで公開/下書き保存のモード管理を行い、context を通じてバリデーション時にスキーマを切り替えています。
import { zodResolver } from "@hookform/resolvers/zod"; import { useRef } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; const draftSchema = z.object({ title: z.string(), content: z.string(), }); const publishSchema = z.object({ title: z.string().min(1, "タイトルは必須です"), content: z.string().min(10, "本文は10文字以上で入力してください"), }); type FormInput = z.infer<typeof draftSchema> | z.infer<typeof publishSchema>; const draftResolver = zodResolver(draftSchema); const publishResolver = zodResolver(publishSchema); export default function App() { const mode = useRef<"draft" | "publish">("draft"); const { register, handleSubmit, formState: { errors }, } = useForm<FormInput>({ context: { mode }, // modeをcontextに渡す resolver: async (values, context, options) => { const currentMode = context?.mode?.current ?? "draft"; // modeに応じてスキーマを切り替え if (currentMode === "draft") { return draftResolver(values, context, options); } else { return publishResolver(values, context, options); } }, }); const onClickSaveAsDraft = () => { mode.current = "draft"; handleSubmit((data) => { alert(JSON.stringify(data)); })(); }; const onClickPublish = () => { mode.current = "publish"; handleSubmit((data) => { alert(JSON.stringify(data)); })(); }; return ( <form> <label> タイトル <input {...register("title")} /> </label> <p>{errors.title?.message}</p> <label> 本文 <input {...register("content")} /> </label> <p>{errors.content?.message}</p> <button type="button" onClick={onClickSaveAsDraft}> 下書き保存 </button> <button type="button" onClick={onClickPublish}> 公開 </button> </form> ); }
以下に CodeSandbox での動作デモを用意しています。
"タイトル"と"本文"を空にした状態で"公開"ボタンを押すとバリデーションエラーが表示されますが、"下書き保存"ボタンを押した場合はバリデーションエラーが表示されません。
useRef を使った理由
上記の例では、公開か下書き保存かを管理するためにuseStateではなくuseRefを使用しています。これは、handleSubmitの直前で状態を変更する際のタイミング問題を回避するためです。
// stateを使った場合の問題例 const [mode, setMode] = useState<"draft" | "publish">("draft"); const onClickPublish = () => { setMode("publish"); // この変更はまだcontextに反映されない return handleSubmit((data) => { // ここではまだ古いmodeが使われる可能性がある alert(JSON.stringify(data)); })(); };
useStateの場合、状態の更新は次のレンダリングサイクルで反映されるため、handleSubmitが実行される時点では更新前の値が参照される可能性があります。
まとめ
React Hook Form の context 機能を活用することで、同一のフォームコンポーネントを使いながら、異なるバリデーションルールを動的に切り替えることが可能になります。
ポケットサインフォームではこの実装により、作業途中でも気軽に保存できるユーザー体験を実現できました。フォーム作成の途中でいつでも作業を中断できるため、ユーザーの利便性向上に大きく貢献しています。
コンポーネントの重複を避けながら柔軟なバリデーション制御を実現したい場合は、ぜひこの手法を検討してみてください。
*1:Reactで使用可能な他の主要フォームライブラリとしては、Formik、TanStack Form、Modular Formsなどが挙げられます。特にTanStack FormやModular Formsはモダンな設計でAPIも洗練されており使いやすい印象でしたが、React Hook Formは歴史が長い分、UIライブラリとの統合例(ポケットサインフォームではChakra UIを使用しています)や実装パターンの情報が豊富で、困ったときの解決策が見つかりやすいため、React Hook Formを採用しています。