dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

RemixとConformで動的なフォームを作成する

はじめに

こんにちは、クラシルリワードのサーバーサイドエンジニアの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.

conform.guide

Remixとの連携

一般的にフロントエンドとバックエンドのあるシステムでは、入力値のバリデーションをフロントエンドとバックエンドの両方で行うことが望ましいです。

ConformはRemixのaction関数とuseActionDataフックを使ってサーバーサイドとクライアントサイドの連携を実現し、zodで定義した型スキーマを使ってデータのバリデーションを行います。
Conformを使うことでRemixのサーバーサイドとクライアントサイドの処理をシームレスに連携できます。

サンプルコード

以下は、RemixとConformを使った動的なフォームのサンプルコードです。
UIコンポーネントにshadcn/uiを使用しています

ui.shadcn.com

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を使ってフォームのスキーマを定義します。ここでは、titlelistsの2つのフィールドを定義しています。listsフィールドは、namelocationを持つオブジェクトの配列で、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や冗長な記述を自動で追加することができます。

conform.guide

{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メソッドを使って、各要素のフィールドセットを取得し、namelocationのフィールドを描画します。

  <Button
        type="button"
        onClick={() =>
          form.insert({
            name: fields.lists.name,
          })
        }
      >
        追加
      </Button>

最後に、form.insertメソッドを使って、listsフィールドに要素を追加するためのボタンを追加します。name属性には、fields.lists.nameを指定します。これはConformのIntent Buttonと呼ばれる機能で、これにより簡単に動的なフォームの実装をすることができます。

conform.guide

まとめ

RemixとConformを組み合わせることで、型安全で動的なフォームを簡単に実装でき、Intent Buttonの機能を使えば複雑なフォームの実装を大幅に簡略化してくれます。

ぜひRemixとConformを使って、効率的にWebアプリケーションを開発してみてください。