【Laravel Precognition】Laravel + React + Inertia環境でライブバリデーションをつくる

# はじめに
今回は、Laravel Precognitionを使用したライブバリデーションについてまとめました。

※おことわり※
基本的に学習内容のアウトプットです。
初学者であるため、間違い等あればご指摘いただけますと嬉しいです。

# この記事の目的
以下内容のアウトプット
- Laravel Precognitionを使用したバリデーションの実装

# この記事の内容
1. Precognitionについて
2. 前提
3. ざっくり
4. ルートの作成
5. バリデーションの作成
6. コントローラの作成
7. Precognitionライブラリをインストール
8. ビューの作成
9. 参考

# 1. Precognitionについて


https://laravel.com/docs/10.x/precognition

>One of the primary use cases of Precognition is the ability to provide "live" validation for your frontend JavaScript application without having to duplicate your application's backend validation rules. 

バックエンドと同じバリデーションルールを、複製することなくフロントエンドに適用することができます。
リアルタイムで検証が行われ、ライブバリデーションを実現できます。

### ライブバリデーションとは

こんな感じで、laravelのFormRequestで実装した検証内容をReactに適用できます。

[![Image from Gyazo](https://i.gyazo.com/5ed99ebaf26dbc269ade846a81763fb2.gif)](https://gyazo.com/5ed99ebaf26dbc269ade846a81763fb2)


# 2. 前提

- Inertia環境のLaravelプロジェクトが構築済み　※1
- Material UIがインストール済み　※2

※1　構築がまだの方は以下から環境構築できます。

https://qiita.com/rikako_hira/items/80e4476ab97630bfd2dc

※2　以下コマンドを実行し、Material UIと関連するモジュールをインストールします。
    
```shell
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled
```

# 3. ざっくり

名前、題名、説明の3点が入力できるフォームを作成します。
フォームの検証にはライブバリデーションを適用し、どのように動くか検証します。

# 4. ルートの作成

以下2つのルートを作成します。

- フォーム
- フォームの送信先

```web.php
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;

// フォーム
Route::get('/form', [FormController::class, 'form'])->name('precognition.form');

// フォームの登録　
Route::post('/create', [FormController::class, 'create'])->middleware([HandlePrecognitiveRequests::class])->name('precognition.create');
                                                        // ^^^^^^^^^^^ Precognitionを有効にする

```

:::note
create()メソッドに対して、middlewareを指定してPrecognitionを有効にします。
:::

# 5. バリデーションの作成

FormRequestを作成します。

```shell
php artisan make:request PrecognitionFormRequest
```

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

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
     */
    public function rules(): array
    {
        return [
            'userName' => ['required', 'max:10'],
            'title' => ['required', 'max:150'],
            'content' => ['required', 'max:1500'],
        ];
    }

    public function messages()
    {
        return [
            'userName.required' => '名前を入力してください。',
            'userName.max' => '名前は10文字以内で入力をしてください。',
            'title.required' => '題名を入力してください。',
            'title.max' => '題名は150文字以内で入力をしてください。',
            'content.required' => '説明を入力してください。',
            'content.max' => '説明は1500文字以内で入力をしてください。',
        ];
    }
}
```

:::note
キー名（userNameなど）はフロントのフィールドに合わせます。
:::

# 6. コントローラの作成

コントローラを作成し、form()とcreate()メソッドを定義します。

```shell
php artisan make:controller FormController
```

```FormController.php
class FormController extends Controller
{
  /**
   * フォームを表示
   * @return Response
   */
  public function form(): Response
  {
      return Inertia::render('Form/FormCreate');
  }

  /**
   * フォームの登録
   * @param PrecognitionFormRequest $request
   * @return RedirectResponse
   */
  public function create(PrecognitionFormRequest $request): RedirectResponse
  {
    // 登録処理を省略
    return to_route('hoge.huga');
  }

```

:::note
create()メソッドにPrecognitionFormRequestを指定します。
:::

# 7. Precognitionライブラリをインストール

laravel-precognition-react-inertiaパッケージをインストールします。

```shell
npm install laravel-precognition-react-inertia
```

:::note
この後の実装で、検証機能が強化されたInertiaのフォームヘルパー `useForm` を使用します。
:::

# 8. ビューの作成

Material UIを使用して、フォームを作成します。

```FormCreate.tsx
import { ChangeEvent } from 'react';
import { useForm } from 'laravel-precognition-react-inertia'; // ※1
import {
    Box,
    Button,
    Card,
    CardContent,
    FormControl,
    Grid,
    TextField,
    Typography,
} from '@mui/material';
import InputError from '@/Components/InputError';

type FormCreateProps = {
    userName: string;
    title: string;
    content: string;
};

export const FormCreate = () => {
    // フォームの初期化 
    const form = useForm<FormCreateProps>('post', route('precognition.create'), { // ※1
        userName: '',
        title: '',
        content: '',
    });

    // 送信
    const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
        e.preventDefault();
        form.submit({
            preserveScroll: true,
            onSuccess: () => {
                form.reset();
            },
            onError: (errors: object) => {
                console.error(errors);
            },
        });
    };

    // 入力項目のonChangeイベント
    const handleChangeUserName = (e: ChangeEvent<HTMLInputElement>): void => {
        form.setData('userName', e.target.value);
        form.forgetError('userName');
    };
    const handleChangeTitle = (e: ChangeEvent<HTMLInputElement>): void => {
        form.setData('title', e.target.value);
        form.forgetError('title');
    };
    const handleChangeContent = (e: ChangeEvent<HTMLInputElement>): void => {
        form.setData('content', e.target.value);
        form.forgetError('content');
    };

    return (
        <Box component="form" onSubmit={handleSubmit}> // ※2
            <Card variant="outlined" sx={{ mt: 5 }}>
                <CardContent>
                    <Typography
                        variant="h6"
                        component="div"
                        sx={{ mb: 1, fontWeight: 'bold', textAlign: 'center' }}
                    >
                        フォーム
                    </Typography>
                    <Grid container sx={{ my: 2 }}>
                        <TextField
                            label={'名前'}
                            required={true}
                            value={form.data.userName}
                            onChange={handleChangeUserName} // ※3
                            onBlur={() => form.validate('userName')} // ※4
                            fullWidth
                            inputProps={{
                                style: {
                                    boxShadow: 'none',
                                },
                            }}
                        />
                        {form.invalid('userName') && (
                            <InputError message={form.errors.userName} className="mt-2" />
                        )} // ※5
                    </Grid>
                    <Grid container sx={{ my: 2 }}>
                        <TextField
                            label={'題名'}
                            required={true}
                            value={form.data.title}
                            onChange={handleChangeTitle}
                            onBlur={() => form.validate('title')}
                            fullWidth
                            inputProps={{
                                style: {
                                    boxShadow: 'none',
                                },
                            }}
                        />
                        {form.invalid('title') && (
                            <InputError message={form.errors.title} className="mt-2" />
                        )}
                    </Grid>
                    <Grid container sx={{ my: 2 }}>
                        <TextField
                            label={'説明'}
                            required={true}
                            value={form.data.content}
                            onChange={handleChangeContent}
                            onBlur={() => form.validate('content')}
                            fullWidth
                            inputProps={{
                                style: {
                                    boxShadow: 'none',
                                },
                            }}
                        />
                        {form.invalid('content') && (
                            <InputError message={form.errors.content} className="mt-2" />
                        )}
                    </Grid>
                    <Grid container>
                        <Button disabled={form.processing} type={'submit'} variant="contained"> // ※6
                            登録
                        </Button>
                    </Grid>
                </CardContent>
            </Card>
        </Box>
    );
};
```

**※1　useForm**

- reactのuseFormではなく、laravel-precognition-react-inertiaの `useForm` を使用する
- useFormを使用して **フォームオブジェクト** を作成する
- 各フィールドに初期値を指定する

**※2　onSubmit**　

- formタグでフォームを囲う
- onSubmitでデフォルトのフォーム送信を防ぎ、 `form.submit` をトリガーする

**※3　onChange**

- 入力値が変更されたとき、 `form.setData` で新しい値をセットする
- `form.forgetError` でエラーを手動でクリアする

**※4　onBlur**　

- 対応するフィールドのバリデーションをトリガーする

**※5　エラーメッセージ**

- 検証エラーかどうか `form.invalid` で判定をして、検証エラーの場合、指定したフィールドのエラーメッセージを表示する

 **※6　多重クリック対策**
- `form.processing` でフォーム送信リクエストが処理中かどうか判定する
- 送信中の場合、ボタンが非活性になり、クリックされるのを防ぐ

[![Image from Gyazo](https://i.gyazo.com/85004cf5a0b443bb0acf82dc39649995.gif)](https://gyazo.com/85004cf5a0b443bb0acf82dc39649995)

### フォームオブジェクト

- **form.reset**：フォームをリセットする
- **form.processing**：フォーム送信リクエストが処理中の場合、falseを返す
- **form.data**：フォームの値を管理する

    ```
    console.log(form.data);
    ```
    
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740809/aabc5e0f-2b27-8bc7-0a7d-9007031a11eb.png)



- **form.validating**：検証リクエストが進行中の場合、trueを返す
- **form.hasErrors**：フォームにエラーがある場合、trueを返す
- **form.valid**：検証を通過した場合、trueを返す
- **form.invalid**：検証エラーの場合、trueを返す
- **form.errors**：検証エラーの場合、エラーが格納される

    ```
    console.log(form.errors);
    ```
    
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740809/7826fbc0-939b-b2c5-7a00-516483d0376a.png)

### 完成したフォーム

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2740809/a6ea60b9-6b50-669a-b423-97bca68e438b.png)

### バリデーションの挙動

[![Image from Gyazo](https://i.gyazo.com/149a44f057809ad25f01b60c24c2a36a.gif)](https://gyazo.com/149a44f057809ad25f01b60c24c2a36a)


# 9. 参考

https://laravel.com/docs/10.x/precognition

https://blog.capilano-fw.com/?p=11085

https://zenn.dev/nshiro/articles/be93d99791b998