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-datauseActionStateを組み合わせることで、かなり実用的な構成が作れます。

Next.js 14/15でフォームを作る際の参考になれば。