Laravel Precognition + React Hook Form + Shadcn/ui で作る!リアルタイムバリデーション
こんにちは!この記事では、Laravel Precognition を使ったリアルタイムバックエンドバリデーションと、React Hook Form (RHF) および Shadcn/ui を組み合わせたモダンなフォーム実装方法について解説します。
今回、RHF と Laravel Precognitionがそれぞれのデータ管理方法が衝突したことで問題が発生しました。
Laravel Precognition は、ユーザーが入力するたびにバックエンドでバリデーションを実行し、即座にフィードバックを返す素晴らしい機能です。しかし、RHF を前提とする Shadcn/ui と連携させるには、少し工夫が必要になります。特に、「Precognition が検知したエラーを、どうやって RHF 管理下の Shadcn/ui コンポーネントに表示するか」という点がポイントです。
私が試行錯誤して解決したやり方をご紹介いたします。
ただ、最適解であるとは限らないため、あくまでやり方の一つとして参考になれば幸いです。
対象読者
- Laravel Precognition (React) を利用している、または検討している方
 - React Hook Form と Shadcn/ui を使ってフォームを構築している方
 - リアルタイムのバックエンドバリデーション結果をフロントエンドUIにスムーズに反映させたい方
 
課題: 2つのフォーム管理ライブラリをどう繋ぐか?
今回取り上げる構成では、以下のライブラリがそれぞれの役割を担います。
- 
laravel-precognition-react(usePrecognitionForm):- バックエンド (Laravel) と通信し、リアルタイムバリデーションを実行。
 - バリデーションエラー (
precognitionForm.errors) を保持。 - フォーム全体の送信 (
precognitionForm.submit) を担当。 
 - 
react-hook-form(useForm as useRHForm):- Shadcn/ui のフォームコンポーネント (
<Form>,<FormField>,<FormMessage>など) と直接連携。 - クライアントサイドでのフォームデータの状態 (
methods.watch,methods.getValues) を管理。 - UIコンポーネントが表示するエラー状態 (
methods.formState.errors) を管理。 
 - Shadcn/ui のフォームコンポーネント (
 
この2つを連携させる上での課題は以下の通りです。
- 
エラーの同期: 
precognitionForm.errorsの内容を、Shadcn/ui が参照するmethods.formState.errorsにリアルタイムで反映させる必要がある。 - 
データの同期: RHF が管理するフォームデータが変更されたら、それを 
precognitionFormに伝え、リアルタイムバリデーションをトリガーする必要がある。 
 解決策: useEffect と watch による双方向同期
useEffect フックと RHF の watch 機能を使って解決しました。
1. エラー同期 (Precognition → RHF) - useEffect
useEffect を使って precognitionForm.errors オブジェクトを監視します。変更があった場合、つまり Precognition がバックエンドから新しいエラー情報を受け取った場合に、RHF の setError メソッドを呼び出して、対応するフィールドのエラー状態 (methods.formState.errors) を更新します。
また、Precognition 側でエラーが解消された場合(precognitionForm.errors から特定のキーが消えた場合)に、RHF 側のエラーも clearErrors でクリアするロジックも含めました。
2. データ同期 & バリデーショントリガー (RHF → Precognition) - watch
RHF の methods.watch 機能は、フォームの入力値が変更されるたびにコールバック関数を実行します。このコールバック内で、以下の処理を行います。
- 
precognitionForm.setData(fieldName, value): 変更されたフィールド名と値をprecognitionFormのデータ状態に同期させます。 - 
precognitionForm.validate(fieldName): 変更されたフィールドに対して、Precognition のリアルタイムバリデーションをトリガーします。 
これにより、ユーザーが入力するたびにバックエンドでバリデーションが実行され、その結果が useEffect を通じて RHF のエラー状態に反映される、という流れが完成します。
実装例
import React, { useEffect, useState } from "react";
import { useForm as useRHForm } from "react-hook-form";
import { useForm as usePrecognitionForm } from "laravel-precognition-react";
import { zodResolver } from '@hookform/resolvers/zod'; // 必要であればクライアントバリデーションも
import * as z from 'zod'; // Zodスキーマ定義用
// Shadcn/ui コンポーネント (例)
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
// フォームデータの型定義 (例)
const formSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  // ... 他のフィールド
});
type FormData = z.infer<typeof formSchema>;
// Precognitionが返す可能性のあるエラーの型を定義
type PrecognitionErrors = Record<keyof FormData | string, string | string[]>; // エラーは文字列または文字列配列の場合がある
export default function MyFormComponent() {
  // Precognition Form Hook
  const precognitionForm = usePrecognitionForm<FormData>(
    "post", // HTTPメソッド
    route("your.validation.endpoint"), // バリデーション用エンドポイント
    { // 初期データ
      email: "",
      name: "",
      // ...
    }
  );
  // React Hook Form Hook
  const methods = useRHForm<FormData>({
    // resolver: zodResolver(formSchema), // クライアントバリデーションも併用する場合
    defaultValues: precognitionForm.data, // 初期値をPrecognitionのデータと同期
  });
  const { setError, clearErrors, watch, handleSubmit, formState } = methods;
  const backendErrors = precognitionForm.errors as PrecognitionErrors; // 型付け
  // --- エラー同期 (Precognition -> RHF) ---
  useEffect(() => {
    // PrecognitionからのエラーをRHFに設定
    Object.keys(backendErrors).forEach((field) => {
      if (field in methods.getValues()) { // フィールドが存在するか確認
        setError(field as keyof FormData, {
          type: "server",
          message: Array.isArray(backendErrors[field]) ? backendErrors[field][0] : backendErrors[field], // エラーメッセージを取得 (配列の場合は最初の要素)
        });
      } else {
        // フォームフィールドに直接関連しないエラーの処理 (例: rootエラー)
        setError("root.serverError", { type: "server", message: backendErrors[field].toString() });
      }
    });
    // Precognitionで解消されたエラーをRHFからクリア
    Object.keys(formState.errors).forEach((field) => {
       // RHFにエラーがあるが、Precognitionにはもうエラーがない場合、それをクリア
       // (ただし、クライアントバリデーションのエラーは保持されるべきなので注意が必要)
       // 提供されたコードの意図に近い形: Precognition側に存在しないRHFエラーをクリア
       if (!(field in backendErrors) && formState.errors[field]?.type === 'server') {
         clearErrors(field as keyof FormData);
       }
       // もしクライアントバリデーション(zodなど)も使うなら、 'server' type のエラーのみクリアするなどの調整が必要
    });
  // backendErrors と RHFの関数を依存配列に追加
  }, [backendErrors, setError, clearErrors, formState.errors, methods]); // formState.errorsも依存配列に入れることでクリアロジックのトリガーになる
  // --- データ同期 & バリデーショントリガー (RHF -> Precognition) ---
  useEffect(() => {
    const subscription = watch((value, { name, type }) => {
      if (name && type === 'change') { // フィールド変更時のみ
        // console.log(`Field changed: ${name}, Value: ${value[name]}`); // デバッグ用ログ
        precognitionForm.setData(name as keyof FormData, value[name]); // データ同期
        precognitionForm.validate(name as keyof FormData); // バリデーショントリガー
      }
    });
    return () => subscription.unsubscribe(); // コンポーネントのアンマウント時に購読解除
  }, [watch, precognitionForm]); // watchとprecognitionFormを依存配列に
  // --- フォーム送信処理 ---
  const onSubmit = (data: FormData) => {
    // handleSubmitが呼ばれる = RHFのバリデーション(あれば)は通過済み
    // Precognitionに最終データをセット (watchで同期してるので不要な場合もあるが念のため)
    precognitionForm.setData(data);
    precognitionForm.submit({
      preserveScroll: true,
      onSuccess: () => {
        console.log("Form submitted successfully with Precognition!");
        precognitionForm.reset(); // Precognitionフォームをリセット
        methods.reset(precognitionForm.data); // RHFフォームもリセット
        // 完了ステップへの遷移など
      },
      onError: (submitErrors) => {
        // 送信時にもエラーが発生する可能性がある (最終バリデーションなど)
        // useEffectでハンドリングされるが、追加処理が必要な場合はここに書く
        console.error("Submission failed:", submitErrors);
      }
    });
  };
  return (
    // Shadcn/uiのFormコンポーネントでラップ
    <Form {...methods}>
      <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
         {/* rootエラー表示エリア */}
         {formState.errors.root?.serverError && (
            <div className="text-red-500 text-sm">{formState.errors.root.serverError.message}</div>
         )}
         <FormField
            control={methods.control}
            name="name"
            render={({ field }) => (
              <FormItem>
                <FormLabel>お名前</FormLabel>
                <FormControl>
                  <Input placeholder="例: 山田 太郎" {...field} />
                </FormControl>
                <FormMessage /> {/* ここでRHF経由でPrecognitionのエラーが表示される */}
              </FormItem>
            )}
          />
         <FormField
            control={methods.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>メールアドレス</FormLabel>
                <FormControl>
                  <Input type="email" placeholder="例: user@example.com" {...field} />
                </FormControl>
                <FormMessage /> {/* ここでRHF経由でPrecognitionのエラーが表示される */}
              </FormItem>
            )}
          />
        {/* 他のフォームフィールドも同様に追加 */}
        <Button type="submit" disabled={precognitionForm.processing || formState.isSubmitting}>
          {precognitionForm.processing ? '送信中...' : '登録する'}
        </Button>
      </form>
    </Form>
  );
}
ポイント解説:
- 
usePrecognitionForm<FormData>(...): Precognition フックを初期化。型引数FormDataを渡すことで、setData,errorsなどで型安全性を高めます。 - 
useRHForm<FormData>(...): RHF フックを初期化。defaultValuesをprecognitionForm.dataと同期させます。 - 
useEffect内のsetError: Precognition からのエラーメッセージ (backendErrors[field]) を RHF の特定フィールドのエラーとして設定します。エラーメッセージが配列の場合があるため、最初の要素[0]を取得するか、toString()などで文字列化します。 - 
useEffect内のclearErrors: RHF に存在するサーバーエラー (type: 'server') で、かつ現在のbackendErrorsに存在しないものをクリアします。これにより、Precognition でエラーが解消された際に UI 上のエラー表示も消えます。- 
注意: クライアントサイドバリデーション(例: Zod)も併用している場合、そのエラーまで消してしまわないように、
formState.errors[field]?.type === 'server'のような条件分岐が重要になります。 
 - 
注意: クライアントサイドバリデーション(例: Zod)も併用している場合、そのエラーまで消してしまわないように、
 - 
onSubmit内のprecognitionForm.submit(): 最終的なフォーム送信は Precognition のsubmitメソッドで行います。 - 
<FormMessage />: この Shadcn/ui コンポーネントが、useEffectによって同期された RHF のエラー (formState.errors) を自動的に拾って表示してくれます。 
まとめ
laravel-precognition-react と react-hook-form (+ Shadcn/ui) は、それぞれ強力なフォーム管理機能を提供しますが、連携させるにはコツが必要でした。
今回紹介した useEffect による エラー同期 と、watch による データ同期 & バリデーショントリガー のパターンは、リアルタイムのバックエンドバリデーションと高機能な UI コンポーネントライブラリを組み合わせる際のアプローチの一つです。
Discussion