はじめに
最近、Remixをいじり始めました。その中でFormの実装にはConformというライブラリを使った方が良い、という情報が多かったので、実際どうなのか試してみました。
まずは、Remixでベーシックなフォームとバリデーションを作り、今度はそれをConformを使って作ってみる、という方法で比較してみたいと思います。
Conformを使わずにフォームを実装
サンプル実装
まずは、loaderまで実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | import { Button, Container, FormControl, FormLabel, Input, Textarea, } from "@mui/joy"; import { type ActionFunctionArgs, json } from "@remix-run/cloudflare"; import { Form, useActionData, useLoaderData } from "@remix-run/react"; const profileDB = { name: "山田太郎", bio: "こんにちは" }; export function loader() { return json(profileDB); } |
loaderでは、データベースを模したconst
profileDB
をそのまま返すのみです。
次に、actionを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | export async function action({ request }: ActionFunctionArgs) { // フォームデータの取得 const formData = await request.formData(); // フォームの値を取得してstringに変換 const name = formData.get("name")?.toString(); const bio = formData.get("bio")?.toString(); // バリデーション if (!name) { return json({ message: "名前は必須です" }); } if (name.length > 10) { return json({ message: "名前は10文字以内で入力してください" }); } if (!bio) { return json({ message: "自己紹介は必須です" }); } if (bio.length > 100) { return json({ message: "自己紹介は100文字以内で入力してください" }); } // データの保存 profileDB.name = name || ""; profileDB.bio = bio || ""; return json({ message: "保存しました" }); } |
actionはサーバサイドで実行されるため、リクエストされてきた値にはバリデーションをかける必要があります。
まず、フォームの値は
formData.get("name")
のように取得できるのですが、この時点での型は
File | string | null
となっています。
今回のフォームは文字列であるため、
?.toString()
することでひとまず
string | null
になります。
数値や日時も一旦
string
になってしまうため、必要に応じてパースする必要があります。
バリデーションでは、必須チェックと文字数チェックをしています。チェックに引っかかった場合は、
return json()
でメッセージをレスポンスします。
バリデーションが全て通った場合のみ、データを保存し、メッセージをレスポンスして終了です。
最後に、コンポーネントのコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | export default function Index() { const actionData = useActionData<typeof action>(); const { name, bio } = useLoaderData<typeof loader>(); return ( <Container> <h2>プロフィール編集</h2> {/* メッセージ表示 */} {actionData && "message" in actionData && <div>{actionData.message}</div>} <Form method="post"> <FormControl> <FormLabel>名前</FormLabel> {/* クライアントバリデーションはHTMLの標準機能を使う */} <Input name="name" defaultValue={name} required slotProps={{ input: { maxLength: 10, }, }} /> </FormControl> <FormControl> <FormLabel>自己紹介</FormLabel> {/* クライアントバリデーションはHTMLの標準機能を使う */} <Textarea name="bio" defaultValue={bio} required slotProps={{ textarea: { maxLength: 100, }, }} /> </FormControl> <Button type="submit">送信</Button> </Form> </Container> ); } |
Joy UIを使っていますが、シンプルなフォームになっています。
先ほどのactionのレスポンスを表示するため、
useActionData<typeof action>()
を使っています。
また、クライアントでのバリデーションはHTMLの標準機能を使っています。基本的にはこのバリデーションが効いているため、サーバサイドでバリデーションエラーとなることはありませんが、このバリデーションを外すことで、actionのバリデーションを試すことができます。
このコードの辛い点
ひとまず、Remixのドキュメントを参考にしながらこのコードを書いてみて、以下の点が辛いなと思いました。
- actionで受け取ることができるフォームの型が
File | string | null
となっていて、数値や日付の場合はパースする必要がある - actionとコンポーネントで、同じバリデーションルールをそれぞれ書く必要がある
サーバサイドとクライアントサイドが別のアプリケーションである場合は、スキーマ駆動開発によってこの2点を解消することができると思います。
Remixの場合は、こういった辛い点をConformを使って乗り切るみたいです。
Conformを使ってみる
ConformはRemixやNext.jsに対応する、型安全なフォームバリデーションライブラリです。バリデーション部分はZodに依存しているのですが、actionやコンポーネントの実装に便利な関数が用意されていて、Remixとうまく統合することができるみたいです。
実際に試してみましょう。
サンプルコード
それでは先ほどのフォームをConformを使って実装してみます。loaderまでは同じなので省略し、actionから進めます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | import { getFormProps, useForm } from "@conform-to/react"; import { getZodConstraint, parseWithZod } from "@conform-to/zod"; import { Button, Container, FormControl, FormHelperText, FormLabel, Input, Textarea, } from "@mui/joy"; import { z } from "zod"; // Zodのスキーマ定義 const schema = z.object({ name: z.string().max(10), bio: z.string().max(100), }); export async function action({ request }: ActionFunctionArgs) { // フォームデータの取得 const formData = await request.formData(); // Zodを使ってバリデーション&パース const submission = parseWithZod(formData, { schema }); // バリデーションエラーの時は、早期リターン if (submission.status !== "success") { return submission.reply(); } // データの保存 profileDB.name = submission.value.name; profileDB.bio = submission.value.bio; // リダイレクトせずレスポンス return submission.reply(); } |
Conformを使うと、
z.object()
によって生成されるZodのスキーマ定義に従ってバリデーションされます。
そのため、
parseWithZod()
を使うだけで、バリデーションができます。
さらに、その戻り値を使い、
submission.reply()
を返すことで、バリデーション結果をレスポンスすることができます。
このバリデーション結果は、あとで登場する、コンポーネント用のhook
useForm
に渡すことで、クライアント側でもエラー内容を取り扱うことができます。
最後に、コンポーネント側のコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | export default function Index() { const actionData = useActionData<typeof action>(); const { name, bio } = useLoaderData<typeof loader>(); // クライアント側でのフォームバリデーションをするためのフック const [form, fields] = useForm({ lastResult: actionData, // サーバサイドからのレスポンスを渡す constraint: getZodConstraint(schema), // Zodのスキーマから制約を生成 shouldValidate: "onBlur", // フォームのバリデーションをどのタイミングで行うか shouldRevalidate: "onInput", // フォームの再バリデーションをどのタイミングで行うか onValidate({ formData }) { // サーバサイドと同じバリデーションをクライアント側でも行う return parseWithZod(formData, { schema }); }, }); return ( <Container> <h2>プロフィール編集</h2> {/* getFormProps()がクライアントバリデーションに必要なpropsを生成している */} <Form method="post" {...getFormProps(form)}> <FormControl error={!!fields.name.errors}> <FormLabel>名前</FormLabel> <Input name="name" defaultValue={name} /> <FormHelperText>{fields.name.errors}</FormHelperText> </FormControl> <FormControl error={!!fields.bio.errors}> <FormLabel>自己紹介</FormLabel> <Textarea name="bio" defaultValue={bio} /> <FormHelperText>{fields.bio.errors}</FormHelperText> </FormControl> <Button type="submit">送信</Button> </Form> </Container> ); } |
actionDataとloaderDataをもらうところまでは同じですね。
その後、
useForm
というConformのフックを使います。このhookを使うと、次のことができるようになります。
- クライアントサイドでのフォームバリデーション
- サーバサイドバリデーションの結果をパース
先ほどとは異なり、バリデーションエラーは
fields.name.errors
のように、項目ごとに取得できるようになるので、項目ごとのヘルパーテキストとして表示するようにしてみました。
また、Formコンポーネントに
{...getFormProps(form)}
を渡すことで、フォーム送信前にクライアント側でのバリデーションが効くようになります。今回は、形式チェックのみなので、クライアントのバリデーションのみで引っかかってくれるのですが、この関数を外すことで、サーバサイドのバリデーションが効いていることを確認することができます。
さいごに
ConformはZodとRemixをうまく連携してくれることが分かりました。それ故に隠蔽された部分も感じられたのですが、それ以上にバリデーションのコードが短くなることが嬉しかったので、これは実践でも使ってみて良いかなと思いました。