📚

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つのフォーム管理ライブラリをどう繋ぐか?

今回取り上げる構成では、以下のライブラリがそれぞれの役割を担います。

  1. laravel-precognition-react (usePrecognitionForm):
    • バックエンド (Laravel) と通信し、リアルタイムバリデーションを実行。
    • バリデーションエラー (precognitionForm.errors) を保持。
    • フォーム全体の送信 (precognitionForm.submit) を担当。
  2. react-hook-form (useForm as useRHForm):
    • Shadcn/ui のフォームコンポーネント (<Form>, <FormField>, <FormMessage> など) と直接連携。
    • クライアントサイドでのフォームデータの状態 (methods.watch, methods.getValues) を管理。
    • UIコンポーネントが表示するエラー状態 (methods.formState.errors) を管理。

この2つを連携させる上での課題は以下の通りです。

  • エラーの同期: precognitionForm.errors の内容を、Shadcn/ui が参照する methods.formState.errors にリアルタイムで反映させる必要がある。
  • データの同期: RHF が管理するフォームデータが変更されたら、それを precognitionForm に伝え、リアルタイムバリデーションをトリガーする必要がある。

解決策: useEffectwatch による双方向同期

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 フックを初期化。defaultValuesprecognitionForm.data と同期させます。
  • useEffect 内の setError: Precognition からのエラーメッセージ (backendErrors[field]) を RHF の特定フィールドのエラーとして設定します。エラーメッセージが配列の場合があるため、最初の要素 [0] を取得するか、toString() などで文字列化します。
  • useEffect 内の clearErrors: RHF に存在するサーバーエラー (type: 'server') で、かつ現在の backendErrors に存在しないものをクリアします。これにより、Precognition でエラーが解消された際に UI 上のエラー表示も消えます。
    • 注意: クライアントサイドバリデーション(例: Zod)も併用している場合、そのエラーまで消してしまわないように、formState.errors[field]?.type === 'server' のような条件分岐が重要になります。
  • onSubmit 内の precognitionForm.submit(): 最終的なフォーム送信は Precognition の submit メソッドで行います。
  • <FormMessage />: この Shadcn/ui コンポーネントが、useEffect によって同期された RHF のエラー (formState.errors) を自動的に拾って表示してくれます。

まとめ

laravel-precognition-reactreact-hook-form (+ Shadcn/ui) は、それぞれ強力なフォーム管理機能を提供しますが、連携させるにはコツが必要でした。

今回紹介した useEffect による エラー同期 と、watch による データ同期 & バリデーショントリガー のパターンは、リアルタイムのバックエンドバリデーションと高機能な UI コンポーネントライブラリを組み合わせる際のアプローチの一つです。

Discussion