🦔

Remix用のフォームバリデーションライブラリを作っている話

2022/02/22に公開

最近はRemixで遊んでいて,サーバとクライアントの境界を曖昧にする設計思想が気に入っています.ただ,色々と実験する中でフォームバリデーション周りに改善の余地があるのではないか,と感じており,自作のライブラリを実装中です.

本記事では,まずRemixでフォームを扱う方法について書きます.
その後,その方法の問題点について触れ,最後に実装中のライブラリがどのように問題を解決するかを書きます.

Remixでフォームを扱う方法について

remixはFormというhtmlのformタグをラップしたコンポーネントを提供していて,フォームのsubmitイベントをハンドルして,フォームの入力内容を非同期通信でサーバに投げてくれます.

remixでは, GETメソッド以外のリクエストがあった際に,action という関数をモジュールからエクスポートしておくと呼び出してくれるので,サーバ側ではその仕組みを使って処理を行います.

実際にコードを書いてみると下記のようになります.

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const title = formData.get("title");

  if (!title) {
    return { title: "title is required" }
  } else {
    await createPost({ title });
    return redirect("/"); 
  }
};

export default function NewPost() {
  const errors = useActionData();
  return (
    <Form method="post">
      <p>
        <label>
          <input type="text" name="title" />
          {errors?.title}
        </label>
      </p>
      <p>
        <button type="submit">submit</button>
      </p>
    </Form>
  );
}

サーバ側では request.formData を呼び出すと FormData オブジェクトを返してくれます.

問題点について

下記2点に問題を感じました.

  • フォームバリデーションを行う組み込みの方法が用意されていない
  • フォームをサブミットしないとバリデーション結果がわからない

サーバ側でバリデーションを行う際,zodやyup等を活用することは可能ですが,FormDataオブジェクトからスキーマバリデーションに適したオブジェクトを作り直す必要があります.

また,より素早くバリデーション結果をユーザにフィードバックすることで,ユーザ体験の向上に繋がると考えています.

React Hook FormやFormikなど,既存のフォームライブラリでも実現は可能ですが,同じようなバリデーション処理をサーバとクライアントで二度書くのは面倒です.
zodやyupで定義したスキーマを共有する方針でも,先に触れたようなオブジェクトの変換処理を行う手間が発生します.

実装中のライブラリでは,これらの問題点を解決することで,ユーザ体験を改善しつつ,バリデーション処理の手間を大幅に削減することができることを目指しています.

実装中のライブラリがどのように問題を解決するか

事前に定義したスキーマを用いて,FormDataオブジェクトに対してバリデーションを実行できるようにする方針でライブラリを実装中です.

このライブラリでは,下記3ステップでフォームバリデーションの実装を行います.

  • スキーマの定義
  • サーバ側でのバリデーション処理記述
  • クライアント側でのバリデーション処理記述

スキーマの定義

スキーマの定義は下記のように行います.
Angularのリアクティブフォームから着想を得ています.

const formGroup = new FormGroup({
  username: new FormControl([
    Validators.minLength({
      len: 1,
      message: "user name must longer than or equal to 1",
    }),
  ]),
});

このスキーマをサーバ・クライアントで使いまわしてバリデーションを行います.

サーバ側でのバリデーション処理

サーバ側でのバリデーションは下記のように書けます.


// formGroup.validate は下記の型のオブジェクトを返します
// type ValidationResult = Record<string, { message: string }[]>;

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const result = formGroup.validate(formData);
  return json({
    ...result,
    username: [
      ...result.username,
      { message: "You can add server validation errors" },
    ],
  });
};

サーバ側でのバリデーション結果はレスポンスボディに含めて返すことで,クライアントに通知する使い方を想定しています.
また,サーバ側でのみ行うようなバリデーション処理の結果を追加してクライアントに返すような使い方も可能です.

クライアント側でのバリデーション処理

クライアント側でのバリデーションには useFormValidation を使います.
resultsプロパティにサーバ側でのバリデーション結果を,formGroupプロパティに定義したスキーマを渡しています.

このフックは返り値としてフォームに渡すプロパティ(formProps)とヘルパー関数(hasError, getErrorなど)を返してくれます.エラーやフォームの状態はヘルパー関数を用いて取得することができます.

const FormPage = () => {
  const { formProps, isDirty, hasError, getError } = useFormValidation({
    formGroup,
    results: useActionData(),
  });

  return (
    <Form method="post" {...formProps}>
      <p>
        <label htmlFor="username">username</label>
        <input type="text" name="username" id="username" />
        {getError("username")?.message}
      </p>
      <p>
        <button type="submit" disabled={hasError() || !isDirty()}>
          submit
        </button>
      </p>
    </Form>
  );
};

export default FormPage;

サーバ側でのバリデーション結果を渡すことで,諸々のヘルパー関数でサーバ側・クライアント側のバリデーション結果を両方見てくれるようになります.

これは,先で述べたようなサーバ側でのみ行うようなバリデーション処理を想定しているためです.

このライブラリを今後どうするか

フォームの扱いを改善したい,というようなissueは既に出ているのですが,現状誰も進めていなさそうです.

[Feature]: Infer form validation from HTML5 attributes? · Issue #539 · remix-run/remix

要望はありそうなので,remixのレポジトリにissueを立てて,可能であればPRを出したいと考えています.

Discussion