💽

RemixでFormライブラリ入れるならConformがオススメなんで使ってみてほしい

2024/01/25に公開

Formライブラリは数あれど、何がいいかなとか経験からReact Hook Formとか使ってる方向けにもし Remix使うなら ってことでConformというライブラリをオススメする記事です。

前提条件

  • Remixの細かい処理方法などは説明しません
  • 各ライブラリのインストール方法などは記載しません

完成コードはこちらを参考にしてください
https://github.com/chimame/remix-form-validation-sample

RemixでのFormライブラリ候補

RemixもReactのフレームワークなんでReact向けに作られていたら使えるのは使えます。例えば候補としては以下が挙がるでしょうか。

https://react-hook-form.com/
https://final-form.org/react
https://www.remix-validated-form.io/

各ライブラリについて詳しくは書きませんが、前2つはReactであれば使えるのでRemixも同様に使えます。最後のRemix Validated FormはRemixならば結構ググったりしてもヒットするし、Remixが出た当初もRemixとの親和性とライブラリが少なったことも相まって、結構使う情報が多かったのではないでしょうか。

とは言ってもRemixも出てきてから既にv2が出るまで結構な時間が経過しております。周辺のライブラリも色々登場してくる中で、この記事でオススメするFormライブラリはこちらです。
https://conform.guide/

ちなみにこのConformですが、@kentcdoddsさんやその他の開発者が経験に基づいた安定した開発が行えるEpick Stackにも採用されています。

https://github.com/epicweb-dev/epic-stack

Conformの特徴

まずは公式から抜粋した内容です。

  • Progressive enhancement first APIs
  • Automatic type coercion with Zod
  • Simplifed integration through event delegation
  • Field name inference
  • Focus management
  • Accessibility support
  • About 5kb compressed

具体的に1つ1つは触れませんがざっくり書くと「サイズが小さく、アクセシビリティのサポートされて、簡単にフォーム検証が出来る」ということが謳われています。この内容に沿って出来るだけ書いて行きたい思います。

Remixとの親和性

まず、ConformはRemix Validated Form同様にRemixとの親和性が高いです。親和性って具体的には FormDataの検証をサーバサイドでも行う ということです。まず普段フロントエンドとバックエンドのあるようなシステムを想像してみてください。その際の入力チェックはどのように行うのが一般的でしょうか?3通り考えられると思います。「フロントエンドだけ入力バリデーションを行う」「バックエンドだけ入力バリデーションを行う」「フロントエンドとバックエンドの両方で入力バリデーションを行う」(フロントエンドもバックエンドも入力バリデーションを行わないとかある??)
正直「フロントエンドだけ入力バリデーションを行う」っていうのが一番オススメしないのですが、このように入力値のバリデーションというのはフロントエンドだけに留まらずバックエンドでも行うことが一般的です。Remixも同様にサーバサイドの処理であるactionでも入力チェックを行うことが普通です。そこでサーバサイドの入力バリデーションはどうするかということが出てきますが、Remix Validated FormとConformはクライアントサイドと同じ記述でサーバサイドの検証が出来ます。そこがRemixとの親和性が高いということです。

Remix Validated FormとConform

ではRemixとの親和性が高いこの2つがありますが、コードを交えて具体的な違いを見ていきます。出来るだけ動作を統一するために入力チェックの条件を以下に書きます。

  • 入力値は「名前」と「メールアドレス」として、必須とする
  • 入力チェックはzodのschemaを使用する
  • 入力した値にエラーがある場合は該当の入力値付近にエラーメッセージを表示する

クライアントでの入力バリデーション

まずはクライアント側に特化した内容を確認していきます。ここではクライアントのエラーの動作を確認するためにわざとinputのtypeやrequired属性を指定していません。実際にコードを書く場合は付与した方がよいです。

Remix Validated Formの場合

入力値バリデーションと各入力値付近に該当のエラーメッセージを表示するためのサンプルです。

import { withZod } from "@remix-validated-form/with-zod";
import {
  ValidatedForm,
  useFormContext,
} from "remix-validated-form";
import { z } from "zod";

export const validator = withZod(
  z.object({
    name: z
      .string()
      .min(1, { message: "Name is required" }),
    email: z
      .string()
      .min(1, { message: "Email is required" })
      .email("Email is invalid"),
  })
);

export default function Form() {
  const { fieldErrors } = useFormContext('sample-form-id');

  return (
    <ValidatedForm id='sample-form-id' validator={validator} method="post">
      <div>
        <label>Name</label>
        <input name="name" />
        { fieldErrors.name && <div>{fieldErrors.name}</div> }
      </div>
      <div>
        <label>Email</label>
        <input name="email" />
        { fieldErrors.email && <div>{fieldErrors.email}</div> }
      </div>
      <button type="submit">Regist</button>
    </ValidatedForm>
  );
}

Remix Validated Formでは以下のような特徴があります。

  • formコンポーネントはRemixから提供されている Form コンポーネントではなくRemix Validated Formが提供している ValidatedFormを使用する
  • 各入力値の状態を取得するために useFormContext を使用して取得する

なんといっても特徴的なのはRemix Validated Formが提供している ValidatedForm です。この ValidatedForm の実態はRemixの Form をラップしたコンポーネントになっており、 onSubmit などを拡張することで入力バリデーション処理などを行っています。

Conformの場合
import { useForm, getFormProps, getInputProps } from "@conform-to/react";
import { parseWithZod } from '@conform-to/zod';
import { Form } from '@remix-run/react';
import { z } from 'zod';

const schema = z.object({
  name: z.string({ required_error: 'Name is required' }),
  email: z
    .string({ required_error: 'Email is required' })
    .email('Email is invalid'),
});

export default function SampleForm() {
  const [form, { name, email }] = useForm({
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
  });

  return (
    <Form method="post" {...getFormProps(form)}>
      <div>
        <label>Name</label>
        <input {...getInputProps(name, { type: "text" })} />
        {name.errors && (
          <div>
            {name.errors.map((e, index) => (
              <p key={index}>{e}</p>
            ))}
          </div>
        )}
      </div>
      <div>
        <label>Email</label>
        <input {...getInputProps(email, { type: "text" })} />
        {email.errors && (
          <div>
            {email.errors.map((e, index) => (
              <p key={index}>{e}</p>
            ))}
          </div>
        )}
      </div>
      <button type="submit">Regist</button>
    </Form>
  );
}

Conformでは以下のような特徴があります。

  • Remixの Form コンポーネントは使用するが動作を拡張するためのPropsを提供する
  • schemaから各入力値の名前などの属性を自動的に決める

Remix Validated Formとは異なりRemixの Form コンポーネントをラップするのではなく、ラップするためのPropsを提供するというのが大きな違いです。また、2つのライブラリのinputを見るとわかりますが、 Conformは name 属性などをshemaから読み取り提供してくれます。

サーバサイドでの入力バリデーション

正直クライアントサイドの違いについては好みの問題レベルで決定的なアドバンテージが出るものは無いと思っています。ところがこの次に提示するサーバサイドでのコードで違いが出てきます。

違いを出すためにサーバサイドの入力チェックに以下の現実的に起こり得る条件を足します。

  • 入力した値がデータベースに登録されている値と整合性があっているかチェックを行う

上記の条件を行うことでエラーであった場合とそうでない場合という分岐が必然と生まれます。

Remix Validated Formの場合
import { type ActionFunctionArgs, json } from "@remix-run/node";
import { validationError } from "remix-validated-form";
import { validator } from "../schemas/form";

export const action = async ({ request }: ActionFunctionArgs) => {
  const data = await validator.validate(await request.formData());
  if (data.error) return validationError(data.error);

  const { name, email } = data.data;

  // 入力値が問題なければデータベースのデータを使った検証を行うサンプル
  if (await userExistsInDatabase(name)) {
    return validationError(
      {
        fieldErrors: {
          name: "This name cannot be used",
        },
        formId: data.formId,
      },
      data.data,
    );
  }

  return json({
    message: "success!!",
  });
};

import { ValidatedForm, useFormContext } from "remix-validated-form";
import { useActionData } from "@remix-run/react";
import { useEffect } from "react";

export default function Form() {
  const data = useActionData<typeof action>();
  const { fieldErrors } = useFormContext("sample-form-id");

  useEffect(() => {
    if (!data) return;

    console.log(data);
    // {
    //   fieleErrors: Record<<field name>, <error message>>,
    //   formId: <your setting form id>
    // }
    // or
    // {
    //   message:  <server message>
    // }

    if ("message" in data) {
      alert(data.message);
    }
  }, [data]);

  return (
    <ValidatedForm id="sample-form-id" validator={validator} method="post">
      ...
    </ValidatedForm>
  );
}

サーバサイドのエラーを表示しつつ、サーバサイドの処理でエラーが無ければメッセージを表示するという処理を書いています。 useEffectを中身を見て分かる通りちょっと辛い部分が出てきます。サーバサイドの返り値の型がRemix Validated Formの ValidatedForm で表示するためのエラーの型と独自の型が混ざるのです。これが使ってるとちょっと面倒くさいなという点として現れてきます。

Conformの場合

今度はConformでサーバサイドのバリデーションを書いてみます。

import type { ActionFunctionArgs } from "@remix-run/node";
import { parseWithZod } from "@conform-to/zod";
import { json } from "@remix-run/node";
import { schema } from "../schemas/form";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema });

  if (submission.status !== "success") {
    return json({
      success: false,
      message: "error!",
      submission: submission.reply(),
    });
  }

  // 入力値が問題なければデータベースのデータを使った検証を行うサンプル
  if (await userExistsInDatabase(submission.value.name)) {
    return json({
      success: false,
      message: "error!",
      submission: submission.reply({
        fieldErrors: {
          name: ["This name cannot be used"],
        },
      }),
    });
  }

  return json({
    success: true,
    message: "success!!",
    submission: submission.reply(),
  });
}

import { useForm, getFormProps } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { Form, useActionData } from "@remix-run/react";
import { schema } from "./schemas/form";
import { action } from "./handlers";
import { useEffect } from "react";

export default function SampleForm() {
  const data = useActionData<typeof action>();
  const [form, { name, email }] = useForm({
    lastResult: data?.submission,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
  });

  useEffect(() => {
    if (!data) return;

    console.log(data);
    // {
    //   message: <server message>,
    //   submission: typeof SubmissionResult,
    //   success: <boolean>,
    // }

    if (data.success) {
      alert(data.message);
    }
  }, [data]);

  return (
    <Form method="post" {...getFormProps(form)}>
      ...
    </Form>
  );
}

export { action };

Conformの場合の useEffect を見るとわかりますが、型をしっかりと決めることが出来るので in などを使って型の内容をチェックする必要がありません。ここがRemix Validated Formとの大きな違いです。実際入力値はZodなどのschemaで決めたとしてもビジネスロジックがサーバサイドなどでバリデーションに入り込むことが容易に想定されます。その場合のエラーハンドリングがどっちがし易いかという点でConformに軍配があがります

Valibotを使うなら

最後に入力バリデーションにzodを使う例を書きましたが、Conformではzodの他にyupも可能です。しかし、zodを使うとJavaScriptのサイズが大きくなってしまうのでzodよりvalibotを使いたいって方もいるのではないでしょうか?かくゆう私自身もそうです。

手前味噌ではありますが、valibot用のparseを行ってくれるものさえ作ればvalibotも使用が可能です。私自身も自分で雑に作ったライブラリを使っています。

https://www.npmjs.com/package/conform-to-valibot

このように自身でparseを書けば入力値チェックは行えるので自分好みのparseを用意するのもいいでしょう。

最後に

Remixはリリースしてから周辺のライブラリも色々と成長してきています。自戒の念も込めていますが今まで使っていたからといって他のライブラリなどに目を向けないのももったいないなと思ってformライブラリについて書いてみました。他のライブラリ等がないか目を向ける機会になれば幸いです。

Discussion