🧩

クライアント・サーバー間の一貫したバリデーション管理: Conform + Server Actions

2024/07/16に公開

はじめに 🚩

フォームの状態管理やクライアントサイドのバリデーションには、React Hook Form(RHF)がよく採用されると思います。しかし、RHF の Server Actions サポートは現在実験的な段階にあり、開発が停滞しています。

https://github.com/react-hook-form/react-hook-form/pull/11061

一方、Conform はすでに Server Actions に対応しています。そこで本記事では、React 19/ Next.js 15 の環境で Conform と Server Actions 使って、クライアントとサーバー間で一貫したバリデーション管理を行うフォームの実装方法を紹介します。具体的には、タグの作成と編集機能を持つコンポーネントを例に説明していきます。

実装例 📝

まずこの記事で使用するファイルを含むフォルダ構成を確認します。

フォルダ構成

index.ts ファイルは、re-export を行っています。

index.ts
export * from "./tag-form"

またschema.tsは、Zod によるスキーマを定義しています。

schema.ts
export const tagSchema = z.object({
  name: z
    .string({ required_error: "Name is required" })
    .min(3, "Name is too short")
    .max(100, "Name is too long"),
})

export type TagSchema = z.infer<typeof tagSchema>

一つポイントを挙げるとすると、このクライアント・サーバー間で使用するスキーマを use-tag-form.ts ファイル内で定義していない点です。

通常、状態管理やクライアントサイドのバリデーション検証を含むフォームコンポーネントはuse clientディレクティブを使用して Client Component として実装されます。同様に、そのコンポーネントで使用されるカスタムフックもクライアントサイドのファイルとして扱われます。

しかし、Server Actions はサーバーサイドで実行されるため、クライアントサイドのファイルからスキーマを直接呼び出すことはできません。

エラー説明

つまり、スキーマを別ファイルとして定義することでサーバーサイドとクライアントサイドの両方で使用できるようになり、エラーを回避しています。

以降、それ以外のファイルについて説明していきたいと思います。

フォームコンポーネント: TagForm

TagForm の全コード
'use client';

import { getInputProps } from '@conform-to/react';
import { useTagForm } from '~/app/supabase/_components/tag-form/use-tag-form';
import { type Tag } from '~/types/data';

type Props = {
  className?: string;
} & (
  | {
      type: 'create';
    }
  | {
      type: 'update';
      defaultValues: Tag;
    }
);

export function TagForm({ className, ...rest }: Props) {
  const { submitAction, loading, form, fields } = useTagForm(rest);

  return (
    <div className={className}>
      <form {...form} action={submitAction}>
        <div>
          <label htmlFor={fields.name.id}>Name</label>
          <input
            required
            {...getInputProps(fields.name, { type: 'text' })}
            key={fields.name.key}
          />
          {fields.name.errors &&
            fields.name.errors.map((error, index) => (
              <div key={index} className='mt-1 text-sm text-red-500'>
                {error}
              </div>
            ))}
        </div>
        <button type='submit' disabled={loading}>
          {loading
            ? 'Loading...'
            : rest.type === 'create'
            ? 'Create'
            : 'Update'}
        </button>
      </form>
    </div>
  );
}
type Props = {
  className?: string;
} & (
  | {
      type: 'create';
    }
  | {
      type: 'update';
      defaultValues: Tag;
    }
);

export function TagForm({ className, ...rest }: Props) {
  const { submitAction, loading, form, fields } = useTagForm(rest);

  return (
    // 省略
  );
}

Props で受け取るtypeに基づいてタグの新規作成か編集かを判断します。
また useTagForm というカスタムフックを呼び出しています。useTagForm については後ほど説明します。

以下、JSX 内で使用しているヘルパー関数について説明します。

getFormProps

<form {...getFormProps(form)} action={submitAction}>
  {/* 省略 */}
</form>

@conform-to/react ライブラリが提供するヘルパー関数で、フォーム要素をアクセシブルにするために必要なすべてのプロパティを返します。

getFormProps を使用することで、以下のようなボイラープレートコードを大幅に削減でき、コードの可読性が向上し、アクセシビリティに関する属性の設定漏れを防ぐことができます。

getFormPropsの使用例

getInputProps

<input
  {...getInputProps(fields.name, { type: 'text' })}
  key={fields.name.key}
/>

@conform-to/react ライブラリが提供するヘルパー関数で、入力要素をアクセシブルにするために必要なすべてのプロパティを返します。

getFormProps の繰り返しになりますが、getInputProps を使用することで、以下のようなボイラープレートコードを大幅に削減でき、コードの可読性が向上し、アクセシビリティに関する属性の設定漏れを防ぐことができます。

getInputPropsの使用例

左のボイラープレートコードを見ると、key プロパティが既に含まれています。右のコードでもスプレッド構文によってこの key プロパティが展開されますが、React における key プロパティの特殊な扱いを考慮すると、スプレッド構文ではなく直接 JSX 要素に渡す必要があります。そのため、getInputProps を使用する際は、key プロパティを別途指定することが推奨されます。

実際、Conform の公式サンプルコードでも、key プロパティは直接指定されています。
https://github.com/edmundhung/conform/blob/6b98c077d757edd4846321678dfb6de283c177b1/examples/nextjs/app/form.tsx#L35-L39

また input 要素だけでなく、textarea や select などの他のフォーム要素にも、それぞれ専用のヘルパー関数が用意されています。詳細については、Conform のドキュメントを参照してください。

https://conform.guide/api/react/getTextareaProps
https://conform.guide/api/react/getSelectProps

エラーメッセージ

return (
  <div className={className}>
    <form {...getFormProps(form)} action={submitAction}>
      <div>
        {/* 省略 */}
        {fields.name.errors &&
          fields.name.errors.map((error, index) => (
            <div key={index} className='mt-1 text-sm text-red-500'>
              {error}
            </div>
          ))}
      </div>
      {/* 省略 */}
    </form>
  </div>
);

Conform はエラーを配列として返すため、複数のエラーメッセージを柔軟に扱うことができます。このエラーはクライアントサイドで発生したエラーとサーバーサイドで発生したエラーを格納します。これにより、ユーザーに対してより詳細で正確なフィードバックを提供することができます。

カスタムフック: useTagForm

use-tag-form の全コード
import { useForm } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { useActionState } from 'react';
import { createTag, updateTag } from '~/app/_actions/tag';
import { tagSchema, type TagSchema } from '~/app/_components/tag-form/schema';
import { type Tag } from '~/types/data';

type Props = { type: 'create' } | { type: 'update'; defaultValues: Tag };

export const useTagForm = (props: Props) => {
  const action =
    props.type === 'create'
      ? createTag
      : updateTag.bind(null, props.defaultValues.id);

  const [state, submitAction, loading] = useActionState(action, {
    status: 'idle',
    submission:
      props.type === 'create'
        ? undefined
        : {
            initialValue: props.defaultValues,
          },
  });

  const [form, fields] = useForm<TagSchema>({
    lastResult: state.submission,
    onValidate({ formData }) {
      return parseWithZod(formData, {
        schema: tagSchema,
      });
    },
    shouldValidate: 'onSubmit',
    defaultValue: props.type === 'create' ? {} : props.defaultValues,
  });

  return { submitAction, loading, form, fields };
};

useTagForm カスタムフックは、タグの作成と編集のためのフォームを処理するためのカスタムフックです。

export const useTagForm = (props: Props) => {
  const action =
    props.type === 'create'
      ? createTag
      : updateTag.bind(null, props.defaultValues.id);

  const [state, submitAction, loading] = useActionState(action, {
    status: 'idle',
    submission:
      props.type === 'create'
        ? undefined
        : {
            initialValue: props.defaultValues,
          },
  });

  // 省略
};

まずタグの作成または編集のタイプを受け取り、それに応じて適切な Server Action を選択します。編集の場合は、既存のタグの id を JavaScript の bind メソッドを使用して、Server Action に追加の引数を渡しています。

そして、useActionState を使用してフォームの状態を管理します。これにより、フォームの送信処理と状態の追跡が効率的に行えます。
useActionState の詳細については、筆者の前回の記事でも説明していますので、参考にしてください。
https://zenn.dev/chot/articles/3d9fb562a2fe95#useactionstate-の利用箇所

また useActionState の第2引数の初期値 (submission) については Conform が提供する SubmissionResult という型に関連する設定です。

export const useTagForm = (props: Props) => {
  // 省略

  const [form, fields] = useForm<TagSchema>({
    lastResult: state.submission,
    onValidate({ formData }) {
      return parseWithZod(formData, {
        schema: tagSchema,
      });
    },
    shouldValidate: 'onSubmit',
    defaultValue: props.type === 'create' ? {} : props.defaultValues,
  });

  return { submitAction, loading, form, fields };
};

useForm は、@conform-to/react ライブラリが提供する React フックで、HTML フォームを強化するためのフォームとフィールドのメタデータを返します。このフックを使用することで、フォームの状態管理やバリデーションを簡単に実装できます。

useForm のオプションについて説明します。

オプション 説明
lastResult フォーム送信結果を指定(ここでは useActionState の返り値 state.submission を指定)。通常サーバーから送信され、progressive enhancement のためにフォームの初期状態として使用
onValidate フォームの(再)バリデーション時に呼び出す関数を定義
shouldValidate バリデーションのタイミングを指定。今回はフォームの項目が一つしかないため、onSubmitでフォームの送信時にバリデーションを実行
defaultValue フォームの初期値を設定

これらのオプションは、useForm フックで利用可能な設定の一部です。より詳細な情報や他のオプションについては、Conform の公式ドキュメントを参照することをおすすめします。

https://conform.guide/api/react/useForm

parseWithZod

parseWithZod は、@conform-to/zod ライブラリが提供する便利なヘルパー関数です。

Zod の safeParse や safeParseAsync と同様にエラーハンドリングを提供し、解析結果として成功または失敗の情報を含むオブジェクトを返しすという点では類似していますが、parseWithZod の特徴としてFormData や URLSearchParams オブジェクトを直接扱えることが挙げられます。

safeParse と parseWithZod の比較
// Zodの safeParse を使用する場合
const result = schema.safeParse(data);
if (result.success) {
  console.log(result.data);
} else {
  console.log(result.error);
}

// parseWithZod を使用する場合
const submission = parseWithZod(formData, { schema });
if (submission.status === 'success') {
  console.log(submission.value);
} else {
  console.log(submission.error);
}

最後に返り値として、フォームのプロパティとフィールドのプロパティを返しています。

Server Actions: createTag, updateTag

tag.ts の全コード
'use server';

import { type SubmissionResult } from '@conform-to/react';
import { parseWithZod } from '@conform-to/zod';
import { type FormState } from '~/app/_actions/types';
import { tagSchema } from '~/app/_components/tag-form/schema';

type FormState =
  | {
      status: 'success';
      message: string;
      submission?: SubmissionResult;
    }
  | {
      status: 'error';
      submission?: SubmissionResult;
    }
  | {
      status: 'idle';
      submission?: SubmissionResult;
    };

export const createTag = async (
  _prevState: FormState,
  data: FormData
): Promise<FormState> => {
  const submission = parseWithZod(data, {
    schema: tagSchema,
  });

  if (submission.status !== 'success') {
    return {
      status: 'error',
      submission: submission.reply(),
    };
  }

  // 認証チェック

  if (!(await isTagNameUnique())) {
    return {
      status: 'error',
      submission: submission.reply({
        fieldErrors: {
          name: ['Name is already taken'],
        },
      }),
    };
  }

  // DB操作

  // 再検証

  return {
    status: 'success',
    message: 'Tag created',
    submission: submission.reply(),
  };
};

export const updateTag = async (
  id: number,
  prevState: FormState,
  data: FormData
): Promise<FormState> => {
  const submission = parseWithZod(data, {
    schema: tagSchema,
  });

  if (submission.status !== 'success') {
    return {
      status: 'error',
      submission: submission.reply(),
    };
  }

  const newTagName = submission.value.name;

  // 入力前の値と入力した値が同じ場合はエラーを返す
  if (prevState.submission?.initialValue?.name === newTagName) {
    return {
      status: 'error',
      submission: submission.reply({
        fieldErrors: {
          name: ['No changes detected'],
        },
      }),
    };
  }

  // 認証チェック

  if (!(await isTagNameUnique())) {
    return {
      status: 'error',
      submission: submission.reply({
        fieldErrors: {
          name: ['Name is already taken'],
        },
      }),
    };
  }

  // DB操作

  // 再検証,リダイレクト
};

const isTagNameUnique = async () => {
  // DB操作
};

createTag, updateTag 共通して、処理の結果を表す 3 つの状態(正常系、異常系、初期状態)を返すように設計しています。これらの状態を表現するために、以下のような Result 型を定義しています。既に簡単な説明はありましたが SubmissionResult は @conform-to/react で提供される型で、クライアントサイドに返すフォームの状態を更新したり、エラーを表示するために使用します。

import { type SubmissionResult } from '@conform-to/react';

export type FormState =
  | {
      status: 'success';
      message: string;
      submission?: SubmissionResult;
    }
  | {
      status: 'error';
      submission?: SubmissionResult;
    }
  | {
      status: 'idle';
      submission?: SubmissionResult;
    };

updateTag の内部処理を見ていきましょう。まず、先ほど紹介したparseWithZod関数を使ってバリデーションを行います。

export const updateTag = async (
  id: number,
  prevState: FormState,
  data: FormData
): Promise<FormState> => {
  const submission = parseWithZod(data, {
    schema: tagSchema,
  });

  if (submission.status !== 'success') {
    return {
      status: 'error',
      submission: submission.reply(),
    };
  }

  const newTagName = submission.value.name; // string

  // 省略
};

このバリデーションが失敗した場合、status としてerrorを返します。同時に、submission.reply()メソッドを使用してSubmissionResultを生成し、submission に設定します。

parseWithZod を使用することで、バリデーション後の値に対して自然な型付けが行われます。つまり、型アサーションなどの強制的な型定義を使わずとも、正しく型付けされた値を得ることができます。

export const updateTag = async (
  id: number,
  prevState: FormState,
  data: FormData
): Promise<FormState> => {
  // 省略

  const newTagName = submission.value.name;

  if (prevState.submission?.initialValue?.name === newTagName) {
    return {
      status: 'error',
      submission: submission.reply({
        fieldErrors: {
          name: ['No changes detected'],
        },
      }),
    };
  }

  if (!(await isTagNameUnique())) {
    return {
      status: 'error',
      submission: submission.reply({
        fieldErrors: {
          name: ['Name is already taken'],
        },
      }),
    };
  }

  // DB操作

  // 再検証,リダイレクト
};

const isTagNameUnique = async () => {
  // DB操作
};

引数のprevStateはフォームの変更前の値を表しています。この値と入力後の newTagName を比較することで、変更がない場合にエラーを返すことができます。

また、isTagNameUnique関数は、データベース内でタグ名が重複していないかを確認するために使用されることを想定しています。

さらにsubmission.reply()メソッドの引数としてfieldErrorsオブジェクトを渡すことで、各フィールドに対応するエラーメッセージを設定できます。これは単にバリデーションエラーだけでなく、データの不整合や権限の問題など、サーバーサイドで発生した様々なエラーを含めることができます。このような仕組みにより、クライアント側ではより詳細で具体的なエラー情報をユーザーに提示することが可能となり、UX の向上につながります。

あとは DB 操作や再検証やリダイレクトを実行して、処理を完了するイメージです。

コンポーネントの利用方法

これまでの実装により、タグの作成と編集を行う再利用可能なコンポーネントが完成しました。
このコンポーネントの使い方は以下のように props を渡して呼び出すことができます。

// 作成
<TagForm type="create" />
// 編集
<TagForm type="edit" defaultValues={tag} />

まとめ 📌

この記事では、1 つのフォームコンポーネントで作成と編集の機能を題材に、Conform と Server Actions を組み合わせることで、クライアントとサーバー間で一貫したバリデーションを実現する方法を紹介しました。

本記事の内容を応用することで、より複雑性が求められるフォーム処理にも対応できるようになると思います。

以上です!

おまけ:useForm > constraint の設定 🎁

Conform のuseFormフックにはconstraintオプションがあり、これを使用することでフォームのバリデーション制約を設定できます。

先程のuseTagFormカスタムフックにconstraintオプションを追加します。

use-tag-form.ts
export const useTagForm = (props: Props) => {
  const action =
    props.type === "create"
      ? createTag
      : updateTag.bind(null, props.defaultValues.id)

  const [state, submitAction, loading] = useActionState(action, {
    status: "idle",
    submission:
      props.type === "create"
        ? undefined
        : {
            initialValue: props.defaultValues,
          },
  })

  const [form, fields] = useForm<TagSchema>({
    lastResult: state.submission,
    onValidate({ formData }) {
      return parseWithZod(formData, {
        schema: tagSchema,
      })
    },
    shouldValidate: "onSubmit",
    defaultValue: props.type === "create" ? {} : props.defaultValues,
+   constraint: getZodConstraint(tagSchema),
  })

  return { submitAction, loading, form, fields }
}

getZodConstraint関数を利用してこの制約を簡単に生成できます。

https://conform.guide/api/zod/getZodConstraint

getZodConstraint関数は、Zod スキーマを解析して各フィールドのバリデーション属性(例:required, minLength, maxLength など)を含むオブジェクトを返します。これにより、HTML の標準的なバリデーション属性が自動的に設定され、ブラウザのネイティブバリデーションを活用できます。

Devtools の HTML エディターで input 要素を確認してみると、constraint の設定の有無によってバリデーション属性が自動的に追加されていることがわかります。

constraintの設定なし
constraint の設定なし

constraintの設定あり
constraint の設定あり

またconstraintを設定しても、onValidate で指定したバリデーション(この場合はparseWithZod)は引き続き実行されます。constraintはあくまで HTML の属性を設定するためのものであり、Conform のバリデーションロジックそのものを置き換えるものではありません。

chot Inc. tech blog

Discussion