Zenn
😀

[Remix] conformを使ったログインフォームの実装

2025/02/13に公開

はじめに

この記事は、Remixアプリケーションのフォーム送信や、バリデーション処理などの一連のフォーム処理の実装について、conformやzodを使った実装についてまとめた記事となります。

今回のソースコード

今回紹介するソースコードは、以下のGihtubのリポジトリで公開しています。

https://github.com/sin-sin-shinji/remix-sample

また、記事での説明のためにコードの内容を一部修正しているため、リポジトリのコードと異なる点があることはご了承ください。

今回の実装環境

今回の実装にあたり利用したライブラリのバージョン等の環境は通りです。

  • Node.js:v22.13.0
  • @remix-run/node: 2.15.2
  • @remix-run/react: 2.15.2
  • @remix-run/serve: 2.15.2
  • remix-auth: 4.1.0
  • remix-auth-form: 3.0.0
  • @conform-to/react: 1.2.2
  • @conform-to/zod: 1.2.2

Conformについて

conformは、フォーム実装に関するnpmライブラリです。詳細は、公式ドキュメントを見ていただくのが一番だと思います。
https://conform.guide/

その中で、「conform」について自分が良いと思った点は、「フォームの送信データの検証が、フロントエンドとサーバーサイドのどちらも同一ロジックで実装可能である点」 です。

機能実装をしている際に、フォームの送信データの検証を、フロントエンドとサーバーサイドのどちらも行うのが一般的ですが、その検証ロジックを両者で別々に実装する必要があって、「処理の内容は同じなのになぁ…」みたいなことが往々にしてありました。

そのため、Conformを用いることで、フォーム送信データの検証を1つにまとめることができる点は、とても魅力的に感じます。

Conformを使ったフォーム実装

今回実装するログインフォーム

今回実装するログインフォームの概要は、以下の通りです。基本的な入力項目として「メールアドレス」と「パスワード」で、ログインユーザーの認証をする素朴な形式です。

今回実装したログインフォーム画面

今回のログインフォームページの実装

まず、今回実装した、ログインフォームページの実装の全体像は、以下の通りとなります。

import { Form, useActionData } from '@remix-run/react';
import { ActionFunctionArgs, redirect } from '@remix-run/node';
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { z } from 'zod';
import { authenticator } from '~/services/auth.server';
import { sessionStorage } from '~/services/session.server';

// zodフォームスキーマ定義
const schema = z.object({
  email: z
    .string({ message: 'メールアドレスを入力してください' })
    .email({ message: 'メールアドレスの形式で入力してください' }),
  password: z.string({ message: 'パスワードを入力してください' }),
});

// Remixの「ログインページ」のコンポーネント
export default function LoginPage() {
  // 前回の送信データを取得する
  const lastResult = useActionData<typeof action>();

  // `conform`のFormロジックを初期設定する
  // この際に、上記の「zodフォームスキーマ定義」を追加することで、
  // フォームの入力項目・バリデーションを設定することができる
  const [form, fields] = useForm({
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
    shouldValidate: 'onBlur',
    shouldRevalidate: 'onInput',
  });

  // ページのHTMLを定義
  // conformで定義したフォーム入力項目と、HTML(入力UI, エラーメッセージ表示など)と連携する
  return (
    <div >
      <Form action="/login" method="post" id={form.id} onSubmit={form.onSubmit}>
        <div>
          <div>
            <div>
              {form.errors && (
                <div>
                  {form.errors.map((error) => (
                    <FormMessage key={error}>{error}</FormMessage>
                  ))}
                </div>
              )}
            </div>
            <div>
              <div>
                <label htmlFor="email">メールアドレス</label>
                <input
                  id="email"
                  key={fields.email.key}
                  name={fields.email.name}
                  type="email"
                  placeholder="メールアドレスを入力してください"
                  aria-invalid={!!fields.email.errors}
                />
                {fields.email.errors && (
                  <FormMessage>{fields.email.errors}</FormMessage>
                )}
              </div>
              <div>
                <label htmlFor="password">パスワード</label>
                <input
                  id="password"
                  key={fields.password.key}
                  name={fields.password.name}
                  type="password"
                  placeholder="パスワードを入力してください"
                  aria-invalid={!!fields.password.errors}
                />
                {fields.password.errors && (
                  <p>{fields.password.errors}</p>
                )}
              </div>
            </div>
          </div>
          <div>
            <button type="submit">ログイン</button>
          </div>
        </div>
      </Form>
    </div>
  );
}

// action定義
// フォーム送信が実行されたら、actionの内容が実行される
export async function action({ request }: ActionFunctionArgs) {
  // フォームの送信データを複製(`clone`)して取得
  // 複製をしないと、後述のログイン認証処理内で、フォーム送信データを参照できなくなってしまう
  const formData = await request.clone().formData();

  // zodフォームスキーマ定義を使って、フォーム送信データを検証
  const submission = parseWithZod(formData, { schema });

  // バリデーションエラーの場合は、以降の処理を行わずに、
  // サーバー側の検証結果を、フロント側に返答する
  if (submission.status !== 'success') {
    return submission.reply();
  }

  try {
    // ログイン認証処理を実行
    const user = await authenticator.authenticate('user-pass', request);

    // ログイン認証処理に成功したら、クッキーにセッション情報を追加し、
    // ログイン後ページへリダイレクト
    const session = await sessionStorage.getSession(
      request.headers.get('cookie')
    );
    session.set('user', user);
    throw redirect('/', {
      headers: { 'Set-Cookie': await sessionStorage.commitSession(session) },
    });
  } catch (error) {
    if (error instanceof Error) {
      // ログイン認証処理に失敗した場合は、エラーを追記して返す
      return submission.reply({
        formErrors: ['メールアドレス、またはパスワードが違います'],
      });
    }

    // 上記以外の意図しないエラーの場合は、再送出する
    throw error;
  }
}

次に、個別の詳細説明を行います。

zodによるフォームのスキーマ定義

まず初めに、zodによるフォームのスキーマ定義を行います。
ここで、フォームの入力項目・バリデーションを設定することができます。

// zodフォームスキーマ定義
const schema = z.object({
  email: z
    .string({ message: 'メールアドレスを入力してください' })
    .email({ message: 'メールアドレスの形式で入力してください' }),
  password: z.string({ message: 'パスワードを入力してください' }),
});

conformを使ったフォーム定義の実装

次に「conformを使ったフォーム定義の実装」について説明します。
Remixのログインページのコンポーネントで以下の様に実装します。

export default function LoginPage() {
  // 前回の送信データを取得する
  const lastResult = useActionData<typeof action>();

  // `conform`のFormロジックを初期設定する
  // この際に、上記の「zodフォームスキーマ定義」を追加することで、
  // フォームの入力項目・バリデーションを設定することができる
  const [form, fields] = useForm({
    lastResult,
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
    shouldValidate: 'onBlur',
    shouldRevalidate: 'onInput',
  });

//~~~~

まず、useActionDataは、Remix関数で、前回のフォーム送信データを取得する処理となります。フォーム送信時、サーバー側の検証処理でエラーが起きた場合、処理結果をここで受け取ることで、ページにエラー内容を表示することができます。

次に、conformによるFormロジックを初期設定を行います。useActionDataでの前回のフォーム送信結果や、zodのフォームのスキーマ定義を指定、その他のフォームのオプションを指定します。

ログインフォームのHTML定義

次に、ログインフォームのHTML定義を行います。

// ページのHTMLを定義
// conformで定義したフォーム入力項目と、HTML(入力UI, エラーメッセージ表示など)と連携する
  return (
    <div >
      <Form action="/login" method="post" id={form.id} onSubmit={form.onSubmit}>
        <div>
          <div>
            <div>
              {form.errors && (
                <div>
                  {form.errors.map((error) => (
                    <FormMessage key={error}>{error}</FormMessage>
                  ))}
                </div>
              )}
            </div>
            <div>
              <div>
                <label htmlFor="email">メールアドレス</label>
                <input
                  id="email"
                  key={fields.email.key}
                  name={fields.email.name}
                  type="email"
                  placeholder="メールアドレスを入力してください"
                  aria-invalid={!!fields.email.errors}
                />
                {fields.email.errors && (
                  <FormMessage>{fields.email.errors}</FormMessage>
                )}
              </div>
              <div>
                <label htmlFor="password">パスワード</label>
                <input
                  id="password"
                  key={fields.password.key}
                  name={fields.password.name}
                  type="password"
                  placeholder="パスワードを入力してください"
                  aria-invalid={!!fields.password.errors}
                />
                {fields.password.errors && (
                  <p>{fields.password.errors}</p>
                )}
              </div>
            </div>
          </div>
          <div>
            <button type="submit">ログイン</button>
          </div>
        </div>
      </Form>
    </div>
  );
}

まず、上記のuseFormで定義したfields変数から、fields.<入力項目名>で入力項目情報にアクセスできるので、入力項目情報と入力UIやエラーメッセージの表示と連携させます。

そして、<Form>タグと、useFormform変数を連携して、submitボタン時のフォーム送信処理を連携させます。

また、form.errorsについては、サーバー側で個別に定義したエラー情報です。ログイン失敗時のエラー表示に利用します。

サーバー側のバリデーション

最後に、サーバー側の処理として、actionの定義を行います。

// action定義
// フォーム送信が実行されたら、actionの内容が実行される
export async function action({ request }: ActionFunctionArgs) {
  // フォームの送信データを複製(`clone`)して取得
  // 複製をしないと、後述のログイン認証処理内で、フォーム送信データを参照できなくなってしまう
  const formData = await request.clone().formData();

  // zodフォームスキーマ定義を使って、フォーム送信データを検証
  const submission = parseWithZod(formData, { schema });

  // バリデーションエラーの場合は、以降の処理を行わずに、
  // サーバー側の検証結果を、フロント側に返答する
  if (submission.status !== 'success') {
    return submission.reply();
  }

  try {
    // ログイン認証処理を実行
    const user = await authenticator.authenticate('user-pass', request);

    // ログイン認証処理に成功したら、クッキーにセッション情報を追加し、ログイン後ページへリダイレクト
    const session = await sessionStorage.getSession(
      request.headers.get('cookie')
    );
    session.set('user', user);
    throw redirect('/', {
      headers: { 'Set-Cookie': await sessionStorage.commitSession(session) },
    });
  } catch (error) {
    if (error instanceof Error) {
      // ログイン認証処理に失敗した場合は、エラーを追記して返す
      return submission.reply({
        formErrors: ['メールアドレス、またはパスワードが違います'],
      });
    }

    // 上記以外の意図しないエラーの場合は、再送出する
    throw error;
  }
}

parseWithZod関数を使うことで、フロント側でも定義したzodのフォームスキーマ定義を元に、送信データの検証をすることができます。もし、バリデーションエラーの場合は、サーバー側の検証結果を、フロント側に返答し、useActionData経由で画面に結果を表示することができます。

そして、フォームバリデーションの検証に成功した場合は、ログイン処理を行います。
詳細は以下の別記事をご確認ください。
https://zenn.dev/sin_sin_shinji/articles/b424eb95b9b587

この時に注意点として、requestオブジェクトを複製(clone)する必要がある点です。複製をしないと、ログイン処理の中でrequestオブジェクト内のフォーム送信データが空オブジェクトとなってしまうからです。

ログイン認証処理に成功したら、クッキーにセッション情報を追加し、ログイン後ページへリダイレクトを行い、処理は終了となります。

ログイン認証失敗時のエラーハンドリング

最後に、ログイン認証処理に失敗した場合の処理を説明します。

今回の実装では、ログイン認証処理に失敗した場合は例外が送出されます。そのため、この場合はtry ~ catchで例外処理を行います。

この時に、conformでは、submission.reply処理時に、formErrorsに個別エラーメッセージを追加することができます。そのため、ログイン認証失敗時のエラーメッセージを付与して、フロント側に検証結果を返します。

return submission.reply({
  formErrors: ['メールアドレス、またはパスワードが違います'],
});

そして、フロント側では、form.errorsから個別エラーメッセージを表示することができます。

{form.errors && (
  <div>
    {form.errors.map((error) => (
      <FormMessage key={error}>{error}</FormMessage>
    ))}
  </div>
)}

おわりに

以上が、今回実装したconformを使った、Remixのログインフォーム画面の実装となります。
conformを用いることで、フロント側とサーバー側のフォームバリデーションを簡単に実装することができました。

今後も、conformを使った、フォームUIの実装を試していきたいと思います。

Discussion

ログインするとコメントできます