Laravel Precognition でリアルタイム・バリデーションをつくる

こんにちは❗フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、「さすがにもうLaravelの進化は止まるでしょ」と何度か考えたことがあるのですが、皆さんもご存知の通りまだまだ進化は止まることはなさそうです。

というのも、この前ツイッターでいつも貴重な情報をいただいている shirocake さんが「Laravel Precognition なる新機能が実装された」という ツイート を投稿され、これがなかなか面白そうだったんからなんですね。(いつも情報ありがとうございます❗)

GitHubのプルリクエストは こちら

このPrecognitionという名前は「事前に」「認知する」とのことで、ざっくり言うと「データ送信する前に、事前に何かをする」というものとなっています。

例えば、ユーザー登録する場合にアカウント名「sukohi」がもう取得済みかどうかは、データ送信時じゃなくて入力の直後に「もう取られちゃってるよ❗」と教えてほしいですよね。(つまり、リアルタイム・バリデーション)

これが実現できるのがLaravel Precognitionです。

Precognitionはヘッダーにデータをセットすることで起動させることができるので、バリデーションだけでなく、共同編集しているページで「いやいや、このページ、ロックされてるから❗」とエラー表示することもできるようです。

そして、今回は私もPrecognition初心者ということで、一番シンプルそうな「リアルタイム・バリデーション」を「Laravel + React」で実装してみることにしました。

ぜひ何かの参考になりましたら嬉しいです😄✨

「ウチの姪っ子が
とある競技の
プロライセンス取りました🎉✨」

開発環境: Laravel 9.34.0、React、Inertia.js、Vite、TailwindCSS

Laravel Precognition を使えるようにする

Laravel Precognitionは、実際にはパッケージではなく2022.09.29Laravel 9.xへ追加されたものです。

そのため、もしそれより前にインストールした場合はLaravel Precognitionは使えませんので、以下のコマンドでLaravel本体をアップデートしておいてください。

composer update

では、楽しんで作業をしていきましょう❗

ルートをつくる

では、まずはルートをつくっていきます。
今回使うのは以下の2つです。

  • 送信フォーム
  • 送信先

routes/web.php

use App\Http\Controllers\PrecognitionFormController;
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;

// 省略

Route::prefix('precognition_form')->controller(PrecognitionFormController::class)->group(function(){

    Route::get('/create', 'create')
        ->name('precognition_form.create');
    Route::post('/', 'store')
        ->middleware([HandlePrecognitiveRequests::class])
        ->name('precognition_form.store');

});

この中で重要なのが、HandlePrecognitiveRequestsを使っている部分です。

実はこのミドルウェアをセットするだけでPrecognitionが有効になります。(つまり、今回はstore()メソッドのみで使えるようになります)

Form Request(バリデーション部分)をつくる

次に、バリデーション部分をつくっておきましょう。
以下のコマンドを実行してください。

php artisan make:request PrecognitionFormRequest

すると、ファイルが作成されるので中身を以下のように変更します。

app/Http/Requests/PrecognitionFormRequest.php

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;

class PrecognitionFormRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules()
    {
        return [
            'name' => 'required|min:5',
            'email' => 'required|email',
            'password' => [ // 例: StrongPassword^0^
                'required',
                Password::min(16)   // 16文字以上
                    ->letters()     // 文字が含まれている
                    ->mixedCase()   // 大文字&小文字が含まれている
                    ->numbers()     // 数字が含まれている
                    ->symbols()     // 特殊文字が含まれている
            ],
        ];
    }
}

ちなみに、Precognitionとは関係ありませんが、パスワードの強度をチェックするため以下のものを含めています。合わせて覚えておくと便利ですよ👍

  • min: *文字以上でないとダメ❗
  • letters: 文字含まれてないとダメ❗
  • mixedCase: (アルファベットの)大文字&小文字が含まれてないとダメ❗
  • numbers: 数字が含まれてないとダメ❗
  • symbols: 特殊文字(「$^」など)が含まれてないとダメ❗

パスワード例: StrongPassword^0^

※ ちなみに友人いわく「特殊文字入れろーとかイライラするよね😫」とのことですが…(笑)

また、パスワードチェックを一元管理したい場合は Password::defaults() も便利ですよ👍

コントローラーをつくる

続いて、コントローラーです。
以下のコマンドを実行してください。

php artisan make:controller PrecognitionFormController

すると、コントローラーが作成されるので中身を以下のようにします。

app/Http/Controllers/PrecognitionFormController.php

<?php

namespace App\Http\Controllers;

use App\Http\Requests\PrecognitionFormRequest;
use Illuminate\Http\Request;
use Inertia\Inertia;

class PrecognitionFormController extends Controller
{
    public function create()
    {
        return Inertia::render('PrecognitionForm/Index');
    }

    public function store(PrecognitionFormRequest $request)
    {
        // ここで何かをする

        return ['result' => true];
    }
}

Inertia + Reactなのでとてもシンプルですが、store()に先ほどのPrecognitionFormRequestをセットするのを忘れないでください。

ビューをつくる

では、最後にReactを使うビューになります。

resources/js/Pages/PrecognitionForm/Index.jsx

import {useEffect, useState} from 'react';
import axios from 'axios';
import _ from 'lodash';

export default function Index() {

    // データ
    const [params, setParams] = useState({
        name: '',
        email: '',
        password: '',
    });
    const [precognitionKey, setPrecognitionKey] = useState('');

    // フォーム値
    const handleInputChange = e => {

        const key = e.target.name;
        const value = e.target.value;

        // 部分バリデーションするキーを保持(useEffect で使う)
        setPrecognitionKey(key);

        // 新しいパラメータをパラメータへ追加
        setParams({
            ...params,
            ...{[key]: value}
        });

    };
    useEffect(() => {

        if(precognitionKey !== '') {

            handleSubmit(true); // Precognition として送信(部分バリデーション)

        }

    }, [params]);

    // 送信
    const handleSubmit = (isPrecognition = false) => {

        const url = route('precognition_form.store');
        let config = getSubmitConfig(isPrecognition);

        axios.post(url, params, config)
            .then(onSubmitSuccess) // 成功したとき
            .catch(onSubmitError); // 失敗したとき

    };
    const getSubmitConfig = isPrecognition => {

        if(isPrecognition === true) { // Precognition の場合はヘッダーをつける

            return {
                headers:{
                    'Accept': 'application/json',
                    'Precognition': 'true',
                    'Precognition-Validate-Only': precognitionKey,
                }
            };

        }

        return {};

    };
    const onSubmitSuccess = response => {

        const isPrecognition = (response.headers.precognition === 'true');

        if(isPrecognition === true) { // Precognition (部分バリデーション)の場合

            const newResponseErrors = {
                ...errors,
                ...{[precognitionKey]: ''} // エラーを削除
            };

            setErrors(newResponseErrors);

        } else { // 本送信

            alert('成功!');

        }

    }
    const onSubmitError = error => {

        let newResponseErrors = {};
        const isPrecognition = (error.response.headers.precognition === 'true');

        if(isPrecognition === true) {

            const errorMessage = _.get(error, `response.data.errors.${precognitionKey}.0`, '');
            newResponseErrors = { // 新しいエラーを追加
                ...errors,
                ...{[precognitionKey]: errorMessage}
            };

        } else {

            const keys = Object.keys(params);
            keys.forEach(key => {

                newResponseErrors[key] = _.get(error, `response.data.errors.${key}.0`, '');

            });

        }

        setErrors(newResponseErrors);

    };

    // エラー
    let [errors, setErrors] = useState({});
    useEffect(() => {

        let responseErrors = {};
        const errorKeys = Object.keys(params);

        errorKeys.forEach(key => { // エラーデータを初期化(自動でパラメータに合わせる)

            responseErrors[key] = '';

        });

    }, []);

    return (
        <div className="p-5 bg-gray-200">
            <h1 className="mb-4 text-center font-medium text-lg">Laravel Precognition でリアルタイム・バリデーション</h1>
            <div className="flex justify-center">
                <div className="w-full max-w-2xl">
                    <div className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
                        <div className="mb-4">
                            <label className="block text-sm font-bold mb-2">
                                名前
                            </label>
                            <input
                                className="border rounded w-full py-2 px-3"
                                type="text"
                                name="name"
                                placeholder="名前"
                                value={params.name}
                                onChange={handleInputChange}/>
                            {errors.name && (
                                <p className="text-red-500 text-xs italic">{errors.name}</p>
                            )}
                        </div>
                        <div className="mb-4">
                            <label className="block text-sm font-bold mb-2">
                                メールアドレス
                            </label>
                            <input
                                className="border rounded w-full py-2 px-3"
                                type="email"
                                name="email"
                                placeholder="メールアドレス"
                                value={params.email}
                                onChange={handleInputChange}/>
                            {errors.email && (
                                <p className="text-red-500 text-xs italic">{errors.email}</p>
                            )}
                        </div>
                        <div className="mb-4">
                            <label className="block text-sm font-bold mb-2">
                                パスワード
                            </label>
                            <input
                                className="border rounded w-full py-2 px-3"
                                type="password"
                                name="password"
                                placeholder="パスワード"
                                value={params.password}
                                onChange={handleInputChange}/>
                            {errors.password && (
                                <p className="text-red-500 text-xs italic">{errors.password}</p>
                            )}
                        </div>
                        <div className="text-right">
                            <button
                                type="button"
                                className="px-4 py-2.5 text-sm font-medium text-center text-white bg-blue-700 rounded-lg"
                                onClick={handleSubmit}>送信する</button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    );

}

少しコードが長くなってしまいましたが、少しずつご紹介します。

専用パッケージについて

Precognitionの説明文には「専用 JavaScript 向けパッケージ」について言及があるのですが、(過去にはnpmのインストールコマンドも書かれていました)が、現在laravel-precognitionというパッケージはnpmには存在していないようでした。

そのため、今回はaxiosで直接Ajax送信することで実装しています。

どうやら 2022.10.05 現在、開発者 Tim MacDonald さんの ツイート を見る限り鋭気作成中のようですね👍

リアルタイム(部分)バリデーション

フォーム値」ブロックでは、入力ボックスに変更があったらリアルタイム(部分的な)バリデーションを実行しています。

ここがPrecognitionを使っている部分なのですが、例えば以下の流れになります。

  1. 名前を「あ」と入力した
  2. 入力した瞬間に「Precognition」モードで「name=あ」をstore()へデータ送信
  3. Precognition が実行され、name だけバリデーションする
  4. 成功 or 失敗を返す

そして、今回のケースでは名前の他にも「メールアドレス」や「パスワード」もあるので、イメージで言うと「飲み会で集まるメンバーに1人ずつ予定を確認していく」ようなカンジです。

そしてPrecognitionはこのように「本送信する前に、先にお伺いを立てる」ような機能ですので、バリデーションだけでなくいろいろな使い方ができると期待されています。

送信部分

コードを短くするために以下2つのパターンを共通化しています。

  • 本送信
  • Precognitionでの(部分バリデーションの)送信

そのため、hundleSubmitにはisPrecognitionパラメータがついていて分岐をしています。

なお、isPrecognitiontrue(つまり、部分バリデーションをする)の場合は、送信に専用ヘッダーをつける必要があり、それがgetSubmitConfig()で書かれています。

また、これもPrecognitionとは関係ないのですが、オブジェクトの合体部分が少しわかりにくいかもしれませんので、ご紹介します。

例えば、以下の部分です。

setParams({
    ...params,
    ...{[key]: value}
});

分かりやすいように以下のようにしてみましょう。

const originalData = {
    name: '山田太郎',
    email: 'taro@example.com',
    password: 'password',
};

const dataKey = 'name';
const dataValue = '佐藤次郎';

const newData = {
    ...originalData,
    ...{[dataKey]: dataValue},
};

console.log(newData);

結論から言うと、これは以下のように出力されます。

{
    "name": "佐藤次郎", // 👈 key が name なのでここが上書きされる
    "email": "taro@example.com",
    "password": "password"
}

しかし、キーが配列ではない場合は注意が必要です。

const newData = {
    ...originalData,
    ...{dataKey: dataValue}, // 👈 キーが配列ではない
};

これはJavaScriptの性質上こうなります。

{
    "name": "山田太郎",
    "email": "taro@example.com",
    "password": "password",
    "dataKey": "佐藤次郎" // 👈 キーが文字列として判断され、ここが追加になる
}

つまり、今回のコードで使っているのは「キーを可変にするため」に配列で指定しているということになります。

ちなみに...の部分は「スプレッド構文」と言ってコードを短くかける方法です。もし興味がありましたら、ぜひ以下をご覧ください。

📝 スプレッド構文を使うと省コードになります

エラー部分

useEffectを使ってページ読み込み時にエラーメッセージの中身を用意しています。

ここではparamsのキー全てに対応するエラーメッセージを用意しているのですが、なぜこうしているかと言うと「もしかすると今後 params の中身が変更になるかもしれないから」です。

もちろん以下のように書いてもコードは動きます。

const [params, setParams] = useState({
    name: '',
    email: '',
    password: '',
});
const [errors, setErrors] = useState({
    name: '',
    email: '',
    password: '',
});

しかし、例えば、paramsに「住所」や「年齢」が追加になったらどうでしょう。

いちいちエラーの方にもキーを追加しないといけなくなります。

また、上のコードは場所が近いから問題は少ないですが、今回のように機能ごとにコードを分けている場合、高い確率で「エラーのキーを追加するのを忘れる」というミスが発生しそうです。

ということで、今回はparamsをループして各データに対するエラーメッセージを自動で用意しています。(これ、アンチパターンじゃないですよね❗❓)

以上で作業は終了です❗
お疲れ様でした😄✨

テストしてみる

では、実際にPrecognitionをテストしてみましょう❗

Viteを起動して「http://******/precognition_form」へブラウザでアクセスします。

すると以下のようなフォームが表示されます。

では、名前の部分に「abc」と入力してみましょう❗

はい❗
今回のバリデーションは(あまり適切ではありませんが)「名前は5文字以上」というルールになっているのでエラーが表示されました。

では、続けて「de」を追加してみましょう。

すると・・・・・・

はい❗
文字が5文字以上になったのでエラーが消えました。

Precognition、成功です😄✨

では、この状態で送信をしてもエラーがちゃんと表示されるかチェックしてみましょう。

どうなったでしょうか・・・・・・

はい❗
メールアドレス」と「パスワード」にエラーが出ました。

では、最後にすべて有効な入力にして送信してみましょう。

うまくいくでしょうか・・・・・・

はい❗
アラートが表示されました。

すべて成功です✨😄👍

デモページをつくりました

今回もせっかくなのでデモページをつくってみました。
ぜひ以下のページから試してみてくださいね。

📝 デモページ

企業様へのご提案

今回のようにPrecognitionを使って実装するとユーザビリティを向上させることもできます。

もしそういったご希望がございましたら、いつでも「お問い合わせ」からお気軽にご連絡ください。

お待ちしております。m(_ _)m

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回は「Laravel + React」でリアルタイム(部分)バリデーションを実装してみました。

Precognitionという新機能が追加されたことで、今後より面倒だったことがシンプルに実装できるようになる可能性を感じさせられました。

ぜひ皆さんも開発の効率化を目指して(私の場合は楽することを目指して)試して見てくださいね。

ではでは〜❗

「年とともに
コーヒー → お茶 → 白湯
と変化。退却戦みたいですね😂」

このエントリーをはてなブックマークに追加       follow us in feedly