RHF × Server Actionsの統合パターン解説
Next.js 14以降でServer Actionsが安定版になって、「もうReact Hook Form要らないんじゃない?」という声を海外のRedditでよく見かけます。実際、シンプルなフォームならServer Actionsだけで十分なケースも多いです。
ただ、複雑なバリデーションやリアルタイムのフィードバックが必要な場合、React Hook Form(以下RHF)との併用が現実的な選択肢になります。今回は、この2つを統合する具体的なパターンを紹介します。
Server Actionsだけで十分?RHFと併用する理由
まず、Server Actionsだけでフォームを作る場合を見てみましょう。
// actions.ts
'use server'
export async function createUser(formData: FormData) {
const name = formData.get('name');
const email = formData.get('email');
// サーバーサイドでバリデーション
if (!name || !email) {
return { error: '入力が不足しています' };
}
// DB保存など
await saveToDatabase({ name, email });
return { success: true };
}
これだけでも動きます。ただ、以下のケースでは物足りなくなります。
- リアルタイムバリデーション: 入力中にエラーを表示したい
- 複雑なフォーム状態: 配列フィールド、条件付きフィールド
- UX重視: ボタンのdisabled制御、入力済みかどうかの判定
RHFはクライアントサイドのUXを担当し、Server Actionsはデータの永続化とセキュリティバリデーションを担当する。海外の記事では、この役割分担をProgressive Enhancementと呼んでいます。
基本の統合パターン:useActionStateとの連携
React 19で追加されたuseActionState(旧useFormState)を使うと、Server Actionsの結果をRHFに渡せます。
// form.tsx
'use client'
import { useForm } from 'react-hook-form';
import { useActionState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { createUser, type FormState } from './actions';
import { userSchema } from './validation';
type FormValues = {
name: string;
email: string;
};
export function UserForm() {
// Server Actionsの結果を受け取る
const [state, formAction] = useActionState<FormState, FormData>(
createUser,
{ success: false, errors: {} }
);
// RHFでクライアントサイドのバリデーション
const {
register,
formState: { errors, isValid },
setError,
} = useForm<FormValues>({
mode: 'onBlur', // onSubmitは避ける(後述)
resolver: zodResolver(userSchema),
errors: state.errors, // サーバーエラーを反映
});
return (
<form action={formAction}>
<input {...register('name')} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit" disabled={!isValid}>
送信
</button>
</form>
);
}
ポイントはmode: 'onBlur'です。海外の記事によると、mode: 'onSubmit'にするとServer Actionsとタイミングが競合して、クライアントバリデーションが正しく動かないケースがあるそうです。
FormDataを直接バリデーションする:zod-form-data
Server Actions側ではFormDataオブジェクトを受け取ります。通常のZodスキーマだと、いちいちObject.fromEntries()で変換する必要があります。
// 通常のパターン(面倒)
const data = Object.fromEntries(formData);
const result = schema.safeParse(data);
海外ではzod-form-dataというパッケージがよく使われています。FormDataを直接パースできるので便利です。
npm install zod-form-data
// validation.ts
import { zfd } from 'zod-form-data';
import { z } from 'zod';
export const userSchema = zfd.formData({
name: zfd.text(z.string().min(1, '名前を入力してください').max(50)),
email: zfd.text(z.string().email('メールアドレスの形式が正しくありません')),
});
Server Actions側ではこう使います。
// actions.ts
'use server'
import { userSchema } from './validation';
import { ZodError } from 'zod';
export type FormState = {
success: boolean;
message?: string;
errors?: Record<string, { message: string }>;
fields?: Record<string, string>;
};
export async function createUser(
prevState: FormState,
formData: FormData
): Promise<FormState> {
try {
// FormDataを直接パース
const { name, email } = userSchema.parse(formData);
await saveToDatabase({ name, email });
return { success: true, message: '登録しました' };
} catch (e) {
if (e instanceof ZodError) {
// エラーをRHF形式に変換
const errors: Record<string, { message: string }> = {};
for (const issue of e.issues) {
errors[issue.path.join('.')] = { message: issue.message };
}
return {
success: false,
errors,
// 入力値を保持(エラー時に消えないように)
fields: Object.fromEntries(formData) as Record<string, string>,
};
}
return { success: false, message: 'エラーが発生しました' };
}
}
同じZodスキーマをクライアントとサーバー両方で使えるので、バリデーションルールの二重定義を避けられます。
useFormStatusでローディング状態を表示する
送信中のローディング表示は、useFormStatusを使います。ただし、このフックは<form>の子コンポーネントでしか動きません。
// SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom';
export function SubmitButton({ disabled }: { disabled?: boolean }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending || disabled}>
{pending ? '送信中...' : '送信'}
</button>
);
}
フォーム側で使う場合:
<form action={formAction}>
{/* ... 他のフィールド */}
<SubmitButton disabled={!isValid} />
</form>
コンポーネントを分ける必要があるのは少し面倒ですが、これがReact公式のパターンです。
よくあるハマりどころと対策
1. 連続送信でデータが消えるバグ
海外の記事で報告されている問題があります。フォームを変更せずに連続で送信すると、stateが更新されないケースがあるそうです。
ワークアラウンドとして、hidden fieldでタイムスタンプを送る方法が紹介されていました。
<form action={formAction}>
<input type="hidden" name="__timestamp" value={Date.now()} />
{/* ... */}
</form>
これでstateの更新が強制されます。ぶっちゃけ、React 19とNext.js 15の組み合わせはまだ荒削りな部分があるようです。
2. サーバーエラーをRHFに反映するタイミング
useEffectでサーバーエラーをRHFのsetErrorに同期する必要があります。
import { useEffect } from 'react';
import { useForm, FieldPath } from 'react-hook-form';
// ...
useEffect(() => {
if (state.errors) {
Object.entries(state.errors).forEach(([field, error]) => {
setError(field as FieldPath<FormValues>, {
message: error.message,
});
});
}
}, [state, setError]);
3. Contextエラー:RHFはクライアントコンポーネントでしか使えない
RHFはReact Contextを使っているため、Server Componentでは動きません。'use client'を忘れずに。
// NG: Server Componentで使おうとする
// page.tsx
import { useForm } from 'react-hook-form'; // エラー
// OK: Client Componentに分離
// form.tsx
'use client'
import { useForm } from 'react-hook-form'; // OK
実際のディレクトリ構成
app/
users/
new/
page.tsx # Server Component(ページ本体)
form.tsx # Client Component(RHF + useActionState)
actions.ts # Server Actions
validation.ts # Zodスキーマ(共有)
submit-button.tsx # useFormStatus用
validation.tsはクライアント・サーバー両方からimportされるので、'use client'も'use server'も付けません。
まとめ
React Hook FormとServer Actionsの統合パターンを紹介しました。
- クライアント: RHFでリアルタイムバリデーション、UX向上
- サーバー: Server Actionsでセキュリティバリデーション、データ永続化
- 共通: Zodスキーマを共有して二重定義を回避
海外の記事でも「DXはまだベストじゃない」と言われていますが、zod-form-dataやuseActionStateを組み合わせることで、かなり実用的な構成が作れます。
Next.js 14/15でフォームを作る際の参考になれば。