はじめに
こんにちは、クラシルリワードのサーバーサイドエンジニアのrakuです!
今回は趣味でRemixを使用した複雑なフォームの実装をする際に便利だった、React向けのtype-safeなフォームライブラリであるConformについてご紹介します。
Conformは、RemixやNext.jsでFormDataの検証をサーバーサイドでも簡単に実装できるため、これらのフレームワークとの相性が抜群です。そのため、Remix Resourcesでも紹介されています。 remix.run
またConformの特徴を公式ドキュメントから引用すると↓と書かれています.
- Progressive enhancement first APIs.
- Type-safe field inference.
- Fine-grained subscription.
- Built-in accessibility helpers.
- Automatic type coercion with Zod.
Remixとの連携
一般的にフロントエンドとバックエンドのあるシステムでは、入力値のバリデーションをフロントエンドとバックエンドの両方で行うことが望ましいです。
ConformはRemixのaction
関数とuseActionData
フックを使ってサーバーサイドとクライアントサイドの連携を実現し、zodで定義した型スキーマを使ってデータのバリデーションを行います。
Conformを使うことでRemixのサーバーサイドとクライアントサイドの処理をシームレスに連携できます。
サンプルコード
以下は、RemixとConformを使った動的なフォームのサンプルコードです。
UIコンポーネントにshadcn/uiを使用しています
import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { getZodConstraint, parseWithZod } from "@conform-to/zod"; import { ActionFunctionArgs, json } from "@remix-run/node"; import { Form, useActionData } from "@remix-run/react"; import { FC, useEffect } from "react"; import { z } from "zod"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; const schema = z.object({ title: z.string({ required_error: "タイトルは必須です" }), lists: z .object({ name: z.string(), location: z.string() }) .array() .nonempty("アイテムを追加してください"), }); export const action = async ({ request }: ActionFunctionArgs) => { const submission = parseWithZod(await request.formData(), { schema }); console.log(submission.reply()); return json({ message: "エラー", submission: submission.reply({ formErrors: ["エラーメッセージ"] }), }); }; const ErrorMessage: FC<{ error?: string[] }> = ({ error }) => { return <div className="text-red-500">{error}</div>; }; export default function TestPage() { const actionData = useActionData<typeof action>(); const [form, fields] = useForm({ lastResult: actionData?.submission, constraint: getZodConstraint(schema), onValidate: ({ formData }) => { return parseWithZod(formData, { schema }); }, }); const lists = fields.lists.getFieldList(); useEffect(() => { if (!actionData?.submission) return; console.log(actionData); if (actionData.submission.status == "error") { alert(actionData.message); } }, [actionData]); return ( <Form method="POST" className="flex flex-col gap-4 p-4" {...getFormProps(form)} > <ErrorMessage error={fields.title.errors} /> <Label htmlFor={fields.title.id} className="block text-sm font-medium text-gray-700" > 買い物リスト </Label> <Input {...getInputProps(fields.title, { type: "text" })} placeholder="Title" /> <ErrorMessage error={fields.lists.errors} /> {lists.map((item, index) => { const itemFields = item.getFieldset(); return ( <div key={index} className="grid grid-cols-2 gap-4"> <div> <Label htmlFor={itemFields.name.id} className="block text-sm font-medium text-gray-700" > 名前 </Label> <ErrorMessage error={itemFields.name.errors} /> <Input className="border-2 border-gray-300 p-2 rounded-md focus:outline-none focus:border-blue-500" {...getInputProps(itemFields.name, { type: "text" })} /> </div> <div> <Label htmlFor={itemFields.location.id} className="block text-sm font-medium text-gray-700" > 場所 </Label> <ErrorMessage error={itemFields.location.errors} /> <Input className="border-2 border-gray-300 p-2 rounded-md focus:outline-none focus:border-blue-500" {...getInputProps(itemFields.location, { type: "text" })} /> </div> </div> ); })} <Button type="button" onClick={() => form.insert({ name: fields.lists.name, }) } > 追加 </Button> <Button type="submit">登録</Button> </Form> ); }
ZodとConformの連携
Conformは、Zodと組み合わせることで、型安全なフォームとバリデーションを実現します。
まず、Zodを使ってフォームのスキーマを定義します。ここでは、title
とlists
の2つのフィールドを定義しています。lists
フィールドは、name
とlocation
を持つオブジェクトの配列で、nonempty
をつけることで空の配列を許容しないようにしています。
const schema = z.object({ title: z.string({ required_error: "タイトルは必須です" }), lists: z .object({ name: z.string(), location: z.string() }) .array() .nonempty("アイテムを追加してください"), });
またformのvalidate結果のエラーメッセージもここで定義します。
次に、フォームの送信時に実行されるremixのaction関数を作成します。ここではConformのparseWithZod
関数を使って、フォームの値をZodのスキーマに従ってパースします。
export const action = async ({ request }: ActionFunctionArgs) => { const submission = parseWithZod(await request.formData(), { schema }); console.log(submission.reply()); if (submission.status !== "success") { return submission.reply(); } return submission.reply({ formErrors: ["エラーメッセージ"], }); };
Zodを用いた入力値の検証結果は、parseWithZod関数が返すオブジェクトの中に格納されています。submission.statusプロパティの値が"success"である場合、入力値が定義されたスキーマ通りであることを示しています。一方、検証でエラーが発生した場合は、submission.reply()を呼び出すことで、エラー情報とユーザーが入力したデータをレスポンスとして返すことができます。
submission.valueからは、フォームの各フィールドの値を取得できます。ここで得られる値は、Zodのスキーマに基づいて適切な型に変換済みとなっています。
useForm
useActionData
フックを使うことで、actionからの戻り値を取得することができます。この結果をuseForm
フックのlastResult
に渡すことで、サーバーサイドのバリデーション結果をクライアントサイドで反映できます。
const lastResult = useActionData<typeof action>(); const [form, fields] = useForm({ lastResult, constraint: getZodConstraint(schema), onValidate: ({ formData }) => { return parseWithZod(formData, { schema }); }, });
ただし、lastResult
は省略可能なので、フォーム側でactionの状態を扱う必要がなければ省略することもできます。
また、onValidateにparseWithZod関数を使うことで、サーバーサイドと同じバリデーション処理をクライアントサイドでも1行で記述できます。
動的なフォームの実装
Conformを使うと、動的にフィールドを追加・削除できるフォームを簡単に実装できます。サンプルコードでは、lists
フィールドが動的なフィールドになっています。
const [form, fields] = useForm({ lastResult, constraint: getZodConstraint(schema), onValidate: ({ formData }) => { const res = parseWithZod(formData, { schema }); return res; }, });
次に、useForm
フックを使ってフォームの状態を管理します。lastResult
には、サーバーサイドから返ってきたバリデーション結果を渡します。constraint
には、先ほど定義したスキーマを渡します。onValidate
には、フォームのバリデーション処理を記述します。ここでは、parseWithZod
関数を使って、フォームのデータをスキーマに基づいてバリデーションしています。
const lists = fields.lists.getFieldList();
getFieldList
メソッドを使って、lists
フィールドの動的なフィールドリストを取得します。これにより、lists
フィールドの要素を動的に追加・削除できるようになります。
inputではgetInputPropsヘルパーを利用することによってa11yや冗長な記述を自動で追加することができます。
{lists.map((item, index) => { const itemFields = item.getFieldset(); return ( <div key={index} className="grid grid-cols-2 gap-4"> <div> <Label htmlFor={itemFields.name.id} className="block text-sm font-medium text-gray-700" > 名前 </Label> <ErrorMessage error={itemFields.name.errors} /> <Input className="border-2 border-gray-300 p-2 rounded-md focus:outline-none focus:border-blue-500" {...getInputProps(itemFields.name, { type: "text" })} /> </div> <div> <Label htmlFor={itemFields.location.id} className="block text-sm font-medium text-gray-700" > 場所 </Label> <ErrorMessage error={itemFields.location.errors} /> <Input className="border-2 border-gray-300 p-2 rounded-md focus:outline-none focus:border-blue-500" {...getInputProps(itemFields.location, { type: "text" })} /> </div> </div> ); })}
lists
フィールドの要素をマップして、動的なフィールドを描画します。getFieldset
メソッドを使って、各要素のフィールドセットを取得し、name
とlocation
のフィールドを描画します。
<Button type="button" onClick={() => form.insert({ name: fields.lists.name, }) } > 追加 </Button>
最後に、form.insert
メソッドを使って、lists
フィールドに要素を追加するためのボタンを追加します。name
属性には、fields.lists.name
を指定します。これはConformのIntent Buttonと呼ばれる機能で、これにより簡単に動的なフォームの実装をすることができます。
まとめ
RemixとConformを組み合わせることで、型安全で動的なフォームを簡単に実装でき、Intent Buttonの機能を使えば複雑なフォームの実装を大幅に簡略化してくれます。
ぜひRemixとConformを使って、効率的にWebアプリケーションを開発してみてください。