はじめに / チュートリアル

チュートリアル

このチュートリアルでは、最初に Remix と Zod だけを使用して基本的なお問い合わせフォームを構築します。その後、 Conform を使用してそれを強化する方法をご紹介します。

#インストール

開始する前に、プロジェクトに Conform をインストールしてください。

npm install @conform-to/react @conform-to/zod --save

#初期設定

まず、スキーマを定義しましょう。ここでは、フォームデータの検証に使用する zod スキーマを示します:

import { z } from 'zod';

const schema = z.object({
  // zodが必要なチェックを適切に実行するためには、前処理ステップが必要です。
  // 空の入力の値は通常、空の文字列であるためです。
  email: z.preprocess(
    (value) => (value === '' ? undefined : value),
    z.string({ required_error: 'Email is required' }).email('Email is invalid'),
  ),
  message: z.preprocess(
    (value) => (value === '' ? undefined : value),
    z
      .string({ required_error: 'Message is required' })
      .min(10, 'Message is too short')
      .max(100, 'Message is too long'),
  ),
});

action ハンドラでは、フォームデータを解析し、zod で検証します。エラーがある場合は、送信された値とともにクライアントに返します。

1import { type ActionFunctionArgs, redirect } from '@remix-run/node';
2import { z } from 'zod';
3import { sendMessage } from '~/message';
4
5const schema = z.object({
6  // ...
7});
8
9export async function action({ request }: ActionFunctionArgs) {
10  const formData = await request.formData();
11
12  // `Object.fromEntries` を使用してオブジェクトを構築します。
13  const payload = Object.fromEntries(formData);
14  // その後、zodでパースします。
15  const result = schema.safeParse(payload);
16
17  // データが有効でない場合は、エラーをクライアントに返します。
18  if (!result.success) {
19    const error = result.error.flatten();
20
21    return {
22      payload,
23      formErrors: error.formErrors,
24      fieldErrors: error.fieldErrors,
25    };
26  }
27
28  // チュートリアルにとって重要ではないので、実装はスキップします。
29  const message = await sendMessage(result.data);
30
31  // メッセージが送信されない場合は、フォームエラーを返します。
32  if (!message.sent) {
33    return {
34      payload,
35      formErrors: ['Failed to send the message. Please try again later.'],
36      fieldErrors: {},
37    };
38  }
39
40  return redirect('/messages');
41}

次に、お問い合わせフォームを実装します。useActionData()から送信結果が返された場合、各フィールドの隣にエラーメッセージを表示します。フィールドは送信された値で初期化されるため、ドキュメントが再読み込みされた場合でもフォームデータが保持されます。

1import { type ActionFunctionArgs } from '@remix-run/node';
2import { Form, useActionData } from '@remix-run/react';
3import { z } from 'zod';
4import { sendMessage } from '~/message';
5
6const schema = z.object({
7  // ...
8});
9
10export async function action({ request }: ActionFunctionArgs) {
11  // ...
12}
13
14export default function ContactUs() {
15  const result = useActionData<typeof action>();
16
17  return (
18    <Form method="POST">
19      <div>{result?.formErrors}</div>
20      <div>
21        <label>Email</label>
22        <input type="email" name="email" defaultValue={result?.payload.email} />
23        <div>{result?.fieldsErrors.email}</div>
24      </div>
25      <div>
26        <label>Message</label>
27        <textarea name="message" defaultValue={result?.payload.message} />
28        <div>{result?.fieldsErrors.message}</div>
29      </div>
30      <button>Send</button>
31    </Form>
32  );
33}

まだ終わっていません。アクセシビリティは決して見過ごされるべきではありません。次の属性を追加して、フォームをよりアクセシブルにしましょう:

  • それぞれのラベルが一意の id を用いて適切に入力と関連付けられていることを確認してください。
  • zod スキーマに似たバリデーション属性を設定する
  • 有効性に基づいてフォーム要素の aria-invalid 属性を設定する
  • エラーメッセージが aria-describedby 属性を用いてフォーム要素にリンクされていることを確認してください。
1import { type ActionFunctionArgs } from '@remix-run/node';
2import { Form, useActionData } from '@remix-run/react';
3import { z } from 'zod';
4import { sendMessage } from '~/message';
5
6const schema = z.object({
7  // ...
8});
9
10export async function action({ request }: ActionFunctionArgs) {
11  // ...
12}
13
14export default function ContactUs() {
15  const result = useActionData<typeof action>();
16
17  return (
18    <Form
19      method="POST"
20      aria-invalid={result?.formErrors ? true : undefined}
21      aria-describedby={result?.formErrors ? 'contact-error' : undefined}
22    >
23      <div id="contact-error">{result?.formErrors}</div>
24      <div>
25        <label htmlFor="contact-email">Email</label>
26        <input
27          id="contact-email"
28          type="email"
29          name="email"
30          defaultValue={result?.payload.email}
31          required
32          aria-invalid={result?.error.email ? true : undefined}
33          aria-describedby={
34            result?.error.email ? 'contact-email-error' : undefined
35          }
36        />
37        <div id="contact-email-error">{result?.error.email}</div>
38      </div>
39      <div>
40        <label htmlFor="contact-message">Message</label>
41        <textarea
42          id="contact-message"
43          name="message"
44          defaultValue={result?.payload.message}
45          required
46          minLength={10}
47          maxLength={100}
48          aria-invalid={result?.error.message ? true : undefined}
49          aria-describedby={
50            result?.error.message ? 'contact-email-message' : undefined
51          }
52        />
53        <div id="contact-email-message">{result?.error.message}</div>
54      </div>
55      <button>Send</button>
56    </Form>
57  );
58}

たとえシンプルなお問い合わせフォームであっても、これは多くの作業を要します。また、すべての ID を維持することはエラーが発生しやすいです。これをどのように簡素化できるでしょう?

#Conform の導入

ここで Conform の出番です。始めるにあたり、 Conform の zod 統合機能が空文字列を自動的に除去してくれるため、 zod スキーマから前処理を削除できます。

1import { z } from 'zod';
2
3const schema = z.object({
4  email: z
5    .string({ required_error: 'Email is required' })
6    .email('Email is invalid'),
7  message: z
8    .string({ required_error: 'Message is required' })
9    .min(10, 'Message is too short')
10    .max(100, 'Message is too long'),
11});

次に、 parseWithZod() ヘルパー関数を使って action を簡素化できます。この関数はフォームデータを解析し、解析された値またはエラーを含む送信オブジェクトを返します。

1import { parseWithZod } from '@conform-to/zod';
2import { type ActionFunctionArgs } from '@remix-run/node';
3import { z } from 'zod';
4import { sendMessage } from '~/message';
5
6const schema = z.object({
7  // ...
8});
9
10export async function action({ request }: ActionFunctionArgs) {
11  const formData = await request.formData();
12
13  // `Object.fromEntries()` をparseWithZodヘルパーに置き換えます。
14  const submission = parseWithZod(formData, { schema });
15
16  // submission が成功しなかった場合、クライアントに送信結果を報告します。
17  if (submission.status !== 'success') {
18    return submission.reply();
19  }
20
21  const message = await sendMessage(submission.value);
22
23  //メッセージが送信されなかった場合は、フォームエラーを返します。
24  if (!message.sent) {
25    return submission.reply({
26      formErrors: ['Failed to send the message. Please try again later.'],
27    });
28  }
29
30  return redirect('/messages');
31}

これで、 useForm フックを使って、すべてのフォームメタデータを管理できます。また、 getZodConstraint() ヘルパーを使用して、 zod スキーマからバリデーション属性を導出します。

1import { useForm } from '@conform-to/react';
2import { parseWithZod, getZodConstraint } from '@conform-to/zod';
3import { type ActionFunctionArgs } from '@remix-run/node';
4import { Form, useActionData } from '@remix-run/react';
5import { z } from 'zod';
6import { sendMessage } from '~/message';
7import { getUser } from '~/session';
8
9const schema = z.object({
10  // ...
11});
12
13export async function action({ request }: ActionFunctionArgs) {
14  // ...
15}
16
17export default function ContactUs() {
18  const lastResult = useActionData<typeof action>();
19  // useFormフックは、フォームをレンダリングするために必要なすべてのメタデータを返します。
20  // そして、フォームが送信されたときに最初の無効なフィールドにフォーカスします。
21  const [form, fields] = useForm({
22    //これにより、サーバーからのエラーが同期されるだけでなく、
23    // フォームのデフォルト値としても使用されます。
24    // プログレッシブエンハンスメントのためにドキュメントが再読み込みされた場合、
25
26    // 最後の結果からすべてのバリデーション属性を導出するために使用します。
27    constraint: getZodConstraint(schema),
28  });
29
30  return (
31    <Form
32      method="post"
33      {/* 追加で必要な属性は `id` 属性のみです。*/}
34      id={form.id}
35      aria-invalid={form.errors ? true : undefined}
36      aria-describedby={form.errors ? form.errorId : undefined}
37    >
38      <div id={form.errorId}>{form.errors}</div>
39      <div>
40        <label htmlFor={fields.email.id}>Email</label>
41        <input
42          id={fields.email.id}
43          type="email"
44          name={fields.email.name}
45          defaultValue={fields.email.initialValue}
46          required={fields.email.required}
47          aria-invalid={fields.email.errors ? true : undefined}
48          aria-describedby={
49            fields.email.errors ? fields.email.errorId : undefined
50          }
51        />
52        <div id={fields.email.errorId}>{fields.email.errors}</div>
53      </div>
54      <div>
55        <label htmlFor={fields.message.id}>Message</label>
56        <textarea
57          id={fields.message.id}
58          name={fields.message.name}
59          defaultValue={fields.message.initialValue}
60          required={fields.message.required}
61          minLength={fields.message.minLength}
62          maxLength={fields.message.maxLength}
63          aria-invalid={fields.message.errors ? true : undefined}
64          aria-describedby={
65            fields.message.errors ? fields.message.errorId : undefined
66          }
67        />
68        <div id={fields.message.errorId}>{fields.message.errors}</div>
69      </div>
70      <button>Send</button>
71    </Form>
72  );
73}

#バリデーション体験の向上

現在、お問い合わせフォームはユーザーが送信したときにのみ検証されます。タイピングするたびにユーザーに早期フィードバックを提供したい場合はどうすればよいでしょうか?

shouldValidate オプションと shouldRevalidate オプションを設定しましょう。

1import { useForm } from '@conform-to/react';
2import { parseWithZod } from '@conform-to/zod';
3import {
4  type ActionFunctionArgs,
5  type LoaderFunctionArgs,
6  json,
7} from '@remix-run/node';
8import { Form, useActionData, useLoaderData } from '@remix-run/react';
9import { sendMessage } from '~/message';
10import { getUser } from '~/session';
11
12const schema = z.object({
13  // ...
14});
15
16export async function loader({ request }: LoaderFunctionArgs) {
17  // ...
18}
19
20export async function action({ request }: ActionFunctionArgs) {
21  // ...
22}
23
24export default function ContactUs() {
25  const user = useLoaderData<typeof loader>();
26  const lastResult = useActionData<typeof action>();
27  const [form, fields] = useForm({
28    // ... previous config
29
30    // Validate field once user leaves the field
31    shouldValidate: 'onBlur',
32    // Then, revalidate field as user types again
33    shouldRevalidate: 'onInput',
34  });
35
36  // ...
37}

この時点で、私たちのお問い合わせフォームはサーバー上でのみ検証され、ユーザーがタイプするたびにフォームを検証するためにサーバーへの往復が発生します。クライアント検証でフィードバックループを短縮しましょう。

1import { useForm } from '@conform-to/react';
2import { parseWithZod } from '@conform-to/zod';
3import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
4import { Form, useActionData, useLoaderData } from '@remix-run/react';
5import { sendMessage } from '~/message';
6import { getUser } from '~/session';
7
8const schema = z.object({
9  // ...
10});
11
12export async function action({ request }: ActionFunctionArgs) {
13  // ...
14}
15
16export default function ContactUs() {
17  const user = useLoaderData<typeof loader>();
18  const lastResult = useActionData<typeof action>();
19  const [form, fields] = useForm({
20    // ... 以前の設定
21
22    //クライアント上で同じ検証ロジックを実行する
23    onValidate({ formData }) {
24      return parseWithZod(formData, { schema });
25    },
26  });
27
28  return (
29    <Form
30      method="post"
31      id={form.id}
32      {/* クライアント検証には `onSubmit` ハンドラが必要です。 */}
33      onSubmit={form.onSubmit}
34      aria-invalid={form.errors ? true : undefined}
35      aria-describedby={form.errors ? form.errorId : undefined}
36    >
37      {/* ... */}
38    </Form>
39  );
40}

#ボイラープレートの削除

Conform がすべての ID とバリデーション属性を管理してくれるのは素晴らしいことです。しかし、フォームとフィールドを設定するのにはまだ多くの作業が必要です。ネイティブ入力を扱っている場合は、 getFormPropsgetInputProps のようなヘルパーを使用してボイラープレートを最小限に抑えることができます。

1import {
2  useForm,
3  getFormProps,
4  getInputProps,
5  getTextareaProps,
6} from '@conform-to/react';
7import { parseWithZod, getZodConstraint } from '@conform-to/zod';
8import { type ActionFunctionArgs } from '@remix-run/node';
9import { Form, useActionData } from '@remix-run/react';
10import { sendMessage } from '~/message';
11
12const schema = z.object({
13  // ...
14});
15
16export async function action({ request }: ActionFunctionArgs) {
17  // ...
18}
19
20export default function ContactUs() {
21  const lastResult = useActionData<typeof action>();
22  const [form, fields] = useForm({
23    // ...
24  });
25
26  return (
27    <Form method="post" {...getFormProps(form)}>
28      <div>
29        <label htmlFor={fields.email.id}>Email</label>
30        <input {...getInputProps(fields.email, { type: 'email' })} />
31        <div id={fields.email.errorId}>{fields.email.errors}</div>
32      </div>
33      <div>
34        <label htmlFor={fields.message.id}>Message</label>
35        <textarea {...getTextareaProps(fields.message)} />
36        <div id={fields.message.errorId}>{fields.message.errors}</div>
37      </div>
38      <button>Send</button>
39    </Form>
40  );
41}

完了です!これが、このチュートリアルで構築した完全な例です:

1import {
2  useForm,
3  getFormProps,
4  getInputProps,
5  getTextareaProps,
6} from '@conform-to/react';
7import { parseWithZod, getZodConstraint } from '@conform-to/zod';
8import { type ActionFunctionArgs } from '@remix-run/node';
9import { Form, useActionData } from '@remix-run/react';
10import { z } from 'zod';
11import { sendMessage } from '~/message';
12
13const schema = z.object({
14  email: z
15    .string({ required_error: 'Email is required' })
16    .email('Email is invalid'),
17  message: z
18    .string({ required_error: 'Message is required' })
19    .min(10, 'Message is too short')
20    .max(100, 'Message is too long'),
21});
22
23export async function action({ request }: ActionFunctionArgs) {
24  const formData = await request.formData();
25  const submission = parseWithZod(formData, { schema });
26
27  if (submission.status !== 'success') {
28    return submission.reply();
29  }
30
31  const message = await sendMessage(submission.value);
32
33  if (!message.sent) {
34    return submission.reply({
35      formErrors: ['Failed to send the message. Please try again later.'],
36    });
37  }
38
39  return redirect('/messages');
40}
41
42export default function ContactUs() {
43  const lastResult = useActionData<typeof action>();
44  const [form, fields] = useForm({
45    lastResult,
46    constraint: getZodConstraint(schema),
47    shouldValidate: 'onBlur',
48    shouldRevalidate: 'onInput',
49    onValidate({ formData }) {
50      return parseWithZod(formData, { schema });
51    },
52  });
53
54  return (
55    <Form method="post" {...getFormProps(form)}>
56      <div>
57        <label htmlFor={fields.email.id}>Email</label>
58        <input {...getInputProps(fields.email, { type: 'email' })} />
59        <div id={fields.email.errorId}>{fields.email.errors}</div>
60      </div>
61      <div>
62        <label htmlFor={fields.message.id}>Message</label>
63        <textarea {...getTextareaProps(fields.message)} />
64        <div id={fields.message.errorId}>{fields.message.errors}</div>
65      </div>
66      <button>Send</button>
67    </Form>
68  );
69}