はじめに
本記事はBASEアドベントカレンダー2024の20日目の記事です。
Pay IDのフロントエンドエンジニアをしているnojiです。
以前執筆した システムリニューアルでNext.jsのApp Router/Server Actionを使って便利だと思ったところ に記載したように、Pay IDのアカウント管理画面ではNext.jsを採用し、Server Actionを活用しています。
今回は、そのServer Action導入時に行ったフォームバリデーション周りの取り組みについて紹介します。
react-hook-formを使ったフォームバリデーション
アカウント管理画面の特性上、ログインだけでなく名前や住所など登録情報の編集といったフォーム操作が必須です。そのため、以下の要件を満たすフォームバリデーションが必要でした。
- 入力中 or 入力後にエラーを検出し、ユーザーに即座に通知できる
- Server Action実行前にクライアント側でバリデーションが実施できる。また、その際に追加処理を挟む柔軟性がある
- バリデーションエラー処理を簡潔に実装できる
これらを実現するため、BASEでも採用実績がある react-hook-form を使うことにしました。
しかし、react-hook-form自体は12/16現在でServer Actionをサポートしているわけではありません。
そこでBETA版のFormコンポーネントを利用し、バリデーション処理とServer Actionの両方を実現できるような実装を行いました。
react-hook-formのFormコンポーネントを利用したフォームバリデーション
従来のreact-hook-formを利用したフォームバリデーション実装は以下のように記述します。formタグのonSubmitにhandleSubmitを渡すことでバリデーション成功時に引数のonSubmit関数が呼ばれます。
const { handleSubmit } = useForm(); const onSubmit = (data) => { // 処理 }; <form onSubmit={handleSubmit(onSubmit)} />
これを、BETA版のFormコンポーネントを使用することで次のように書き換えられます。
<Form onSubmit={({ data, formData, formDataJson, event }) => { // 処理 }} />
onSubmitはバリデーション成功時に呼び出される関数として機能します。引数には、オブジェクト形式、FormData形式、JSON形式でフォームデータを受け取れるため、柔軟な処理が可能になります。 Formコンポーネントには他にもいくつかpropsの項目が用意されているので、気になる方はドキュメントをご確認ください
実装例
Server Actionとの連携
Server Actionは、useActionStateを組み合わせて使用します。フォームのアクション結果に基づきstateを更新してくれるhooksになります。また、バリデーションスキーマには zod を採用し、サーバー側とクライアント側で共通化しました。
以下は、Server Actionとフォームバリデーションの実装例です。
バリデーションスキーマの定義例
// schema.ts export const schema = z.object({ email: z.string() .min(1, 'メールアドレスを入力してください') .email('メールアドレスの形式が正しくありません'), });
Server Actionの実装例
// server-action.ts export const serverAction = async (prevState: any, formData: FormData) => { try { const data = schema.parse({ email: formData.get('email'), }); await fetch('https://backend-api-example.com/v1/hoge', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data), }); redirect('/complete'); } catch (error) { return { code: 'server_error', message: 'エラーが発生しました', }; } };
Formコンポーネントの実装例
// form-client-component.tsx 'use client'; import { useActionState } from 'next/server-actions'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { schema } from './schema'; const FormClientComponent = () => { const [state, formAction, isPending] = useActionState(serverAction); const { register, control, formState: { errors }, } = useForm({ resolver: zodResolver(schema) }); return ( <div> {state?.message && <p>{state.message}</p>} {/* サーバー側エラーメッセージの表示 */} <Form control={control} onSubmit={({ formData }) => formAction(formData)} > <label htmlFor="email">メールアドレス</label> <input {...register('email')} type="email" id="email" /> {errors.email && <p>{errors.email.message}</p>} {/* クライアント側バリデーションエラー表示 */} <button type="submit">送信</button> </Form> </div> ); };
FormコンポーネントのonSubmit内でServerActionを実行するようにしています。
Formコンポーネントのpropsでactionも渡すことができるのですが、このactionについてはfetchを呼び出すためのもののようで、現状formActionを渡してもServerActionをうまく実行できなかったので使わないようにしました(今後はここが修正されてactionで実行できるようになるかもしれません)
カスタム処理の追加例
onSubmit内で非同期処理を実行し、formDataをカスタマイズすることも可能です。
onSubmit={async ({ formData }) => { const token = await getToken(); formData.append('token', token); formAction(formData); }}
使ってみての感想
ServerActionとreact-hook-formのFormコンポーネントを組みわせることで以下の部分が便利だと思いました。
- FormコンポーネントのonSubmitがバリデーション成功時しか呼ばれないため、バリデーション制御処理をすべてreact-hook-form側に寄せることができたこと。また、ServerAction実行前にformData等に値を追加したりクライアント側で非同期処理を呼び出したりできること。
- useActionStateを利用していることで、onSubmit内でServerActionを呼ぶだけでよく、レスポンスのハンドリングや状態管理についてクライアント側で考えなくて良くなったこと。
おわりに
現時点では、react-hook-formのFormコンポーネントはBETA版のため注意が必要ですが、将来的にServer Actionを完全サポートする可能性も考えられます。その際には今回の実装をさらに簡略化できるかもしれません。今後も動向をウォッチしながら改善を進めていければと思います。
Server Actionとreact-hook-formの連携について紹介しましたが、皆さんのプロジェクトの参考になれば幸いです。
最後に、Pay IDではエンジニアを募集しています。興味がある方はお気軽にご応募ください!