🐷

Inertia.js 2.1で変わったフォームの書き方:Wayfinder + <Form> 実践ガイド

に公開

inertia.js 2.1から使える<Form>に加えちょっと前のバージョンから使えるwayfinderが組み合わされた事により、現在のスターターキットには全面的に採用されてコードが変更されているので細かく見てく事にしよう。

スターターキットにuseFormはもう使われていない

現時点(2025/10/21)ではもうuseFormを使った記法は無い

rg -i useform resources/ # 出力なし

さらにroute()も使われていない

rg 'route\s*\(' resources/ # 出力なし

Laravel Wayfinder

https://github.com/laravel/wayfinder

現在でもまだbetaとされているが、普通に広く使われており、バグが放置される可能性は低そうだ。

https://github.com/laravel/wayfinder/blob/main/CHANGELOG.md

インストールに関しては個別の方法がドキュメントされているが、スターターキットには同梱されているので特別な処理は不要。

Vite の設定もスターターキットに同梱されているため、個別設定は不要

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.tsx'],
            ssr: 'resources/js/ssr.tsx',
            refresh: true,
        }),
        react(),
        tailwindcss(),
        // ↓ これ
        wayfinder({
            formVariants: true,
        }),
    ],
    esbuild: {
        jsx: 'automatic',
    },
});

さらにデフォルトでは resources/js 配下に wayfinder・actions・routes の3ディレクトリが生成され、これを .gitignoreするとよいと、ドキュメントされているが、やはりスターターキットでは既に処理が行われているので不要。

# この辺
/resources/js/actions
/resources/js/routes
/resources/js/wayfinder

ただし、これらは後付けで追加しようとした場合はもちろん自分で面倒を見る必要がある。

使い方

これは従来のroute()に代わるものである。ここでスターターキットのroutes/*以下をポイントするためのいくつかの手法を示す。まず、現在のroutes/web.phpは以下の通り

routes/web.php
<?php

use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get('/', function () {
    return Inertia::render('welcome');
})->name('home');

Route::middleware(['auth', 'verified'])->group(function () {
    Route::get('dashboard', function () {
        return Inertia::render('dashboard');
    })->name('dashboard');
});

require __DIR__.'/settings.php';
require __DIR__.'/auth.php';

ここで、たとえばdashboardにポイントするには以下のようにする

+import { dashboard } from '@/routes';
// <snip>...
 <main>
+  <pre>
+    {JSON.stringify(dashboard(), null, 2)}
+  </pre>
 </main>


対応するurlmethodが表示される

他のrouting

今見た例は特殊なルートなので、他も見てみよう

routes/auth.php
<?php

use App\Http\Controllers\Auth\NewPasswordController;
use App\Http\Controllers\Auth\PasswordResetLinkController;
use App\Http\Controllers\Auth\RegisteredUserController;
use Illuminate\Support\Facades\Route;

Route::middleware('guest')->group(function () {
    Route::get('register', [RegisteredUserController::class, 'create'])
        ->name('register');

    Route::post('register', [RegisteredUserController::class, 'store'])
        ->name('register.store');

    Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
        ->name('password.request');

    Route::post('forgot-password', [PasswordResetLinkController::class, 'store'])
        ->name('password.email');

    Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
        ->name('password.reset');

    Route::post('reset-password', [NewPasswordController::class, 'store'])
        ->name('password.store');
});

ここで、たとえば/registerを取る場合、ここではregisterというのとregister.storeというのがあるので以下のようにして比べてみるとよいだろう。

+import { dashboard } from '@/routes';
+import { store } from '@/routes/register';
// <snip>...
 <main>
+  <pre>
+    {JSON.stringify(register(), null, 2)}
+    {JSON.stringify(store(), null, 2)}
+  </pre>
 </main>


register()およびstore()の内容

実際にはこのようなネーミングは混乱の元なので実際に使う場合は整えてもらうとして、こうした形で Laravel のルート情報を取得する新しい手法 であることがわかる。

inertia.jsとの連携

When using Inertia, you can pass the result of a Wayfinder method directly to the submit method of useForm, it will automatically resolve the correct URL and method:
(Inertia.js使用時は、Wayfinder 関数を useForm().submit() に直接渡せます)
https://github.com/laravel/wayfinder?tab=readme-ov-file#wayfinder-and-inertia

にあるように

import { useForm } from "@inertiajs/react";
import { store } from "@/actions/App/Http/Controllers/PostController";

const form = useForm({ name: "My Big Post" });
form.submit(store()); // POST /posts

とか書く事ができる。これを踏まえて次の<Form>との統合を見ていく事にしよう。

Inertia.js 2.1の<Form>

まず、大前提として、wayfinderは従来のroute()を完全に置き換えるものであるが、こちらの<Form>useForm()を置き換えるものではなく、あくまで簡単に書けるよ的なものだ。<Form>で満足できない場合はuseForm()の使用を考える事。

従来の書き方

import { useForm } from '@inertiajs/react';

export default function CreateUser() {
  const { data, setData, post, processing, errors } = useForm({
    name: '',
    email: '',
  });

  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    post('/users');
  };

  return (
    <form onSubmit={submit} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">Name</label>
        <input
          type="text"
          value={data.name}
          onChange={(e) => setData('name', e.target.value)}
          className="border rounded w-full p-2"
        />
        {errors.name && <p className="text-red-500 text-sm">{errors.name}</p>}
      </div>

      <div>
        <label className="block text-sm font-medium">Email</label>
        <input
          type="email"
          value={data.email}
          onChange={(e) => setData('email', e.target.value)}
          className="border rounded w-full p-2"
        />
        {errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
      </div>

      <button
        type="submit"
        disabled={processing}
        className="px-4 py-2 bg-blue-600 text-white rounded"
      >
        Create User
      </button>
    </form>
  );
}

バックエンド

Route::post('/users', function (Request $request) {
    dd($request->all());
})->name('users');

とすると


適当に入力して送信


受信結果

となる。これは非常によく使っていた機能だし便利なのだが、これを<Form>で書いてみよう

<Form> で書いた例

import { Form } from '@inertiajs/react';

export default function CreateUser() {
  return (
    <Form
      action="/users"
      method="post"
      className="space-y-4"
    >
      <div>
        <label className="block text-sm font-medium">Name</label>
        <input
          type="text"
          name="name"
          className="border rounded w-full p-2"
        />
      </div>

      <div>
        <label className="block text-sm font-medium">Email</label>
        <input
          type="email"
          name="email"
          className="border rounded w-full p-2"
        />
      </div>

      <button
        type="submit"
        className="px-4 py-2 bg-blue-600 text-white rounded"
      >
        Create User
      </button>
    </Form>
  );
}

こんな感じになる。ここまでくると HTML1 時代の雰囲気すらあるが、これで動作してしまう。

processingとかの対応

ここでは簡単な例をまず書いた為、processingを割愛したが、これらを含める事もできる。
基本的にはSlot Props https://inertiajs.com/forms#slot-props を見て欲しい。以下はちょっと足してみた例

<Form action="/users" method="post" className="space-y-4">
  {({ processing, wasSuccessful, errors }) => (
    <>
      <div>
        <label className="block text-sm font-medium">Name</label>
        <input
          type="text"
          name="name"
          className="border rounded w-full p-2"
        />
        {errors.name && (
          <p className="text-red-500 text-sm">{errors.name}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">Email</label>
        <input
          type="email"
          name="email"
          className="border rounded w-full p-2"
        />
        {errors.email && (
          <p className="text-red-500 text-sm">{errors.email}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={processing}
        className={`px-4 py-2 rounded text-white ${
          processing
            ? 'bg-blue-400 cursor-not-allowed'
            : 'bg-blue-600 hover:bg-blue-700'
        }`}
      >
        {processing ? 'Creating...' : 'Create User'}
      </button>

      {wasSuccessful && (
        <p className="text-green-600 text-sm mt-2">
          User created successfully!
        </p>
      )}
    </>
  )}
</Form>


processing()が正常に発動している

wayfinderとForm

ここまでwayfinderとFormを見てきたが、これらを統合する方法をやってみよう。

たとえばこういうルートがあると

Route::post('/users', function (Request $request) {
    sleep(10);
    dd($request->all());
})->name('users.store');

ここに送信するとした時に

今現状は

<Form action="/users" method="post" className="space-y-4">

としているが、/usersではなくwayfinderを使うには

resources/js/pages/welcome.tsx
+import { store } from '@/routes/users';
 import { type SharedData } from '@/types';
 import { Head, Link, usePage } from '@inertiajs/react';
 // import { useForm } from '@inertiajs/react';
@@ -51,7 +51,7 @@ export default function Welcome() {
                 </header>
 <div className="flex w-full items-center justify-center opacity-100 transition-opacity duration-750 lg:grow starting:opacity-0">
 <main className="flex w-full max-w-[335px] flex-col-reverse lg:max-w-4xl lg:flex-row">
-<Form action="/users" method="post" className="space-y-4">
+<Form action={store.url()} method="post" className="space-y-4">
   {({ processing, wasSuccessful, errors }) => (
     <>
       <div>

このようにaction先をstore.url()としている。しかし、より密接な統合が可能だ

-<Form action="/users" method="post" className="space-y-4">
+<Form action={store} className="space-y-4">

このようにstore()を渡すとmethodだの何だのが省略可能になる。詳しくは https://inertiajs.com/forms#wayfinder を参照

laravel starter kitでの使われ方

たとえば resources/js/pages/auth/login.tsxでは

<Form
  {...store.form()}
  resetOnSuccess={['password']}
  className="flex flex-col gap-6"
>
  {({ processing, errors }) => (
    <>
      {/* 略 */}
      <Button
        type="submit"
        className="mt-4 w-full"
        tabIndex={4}
        disabled={processing}
        data-test="login-button"
      >
        {processing && <Spinner />}
        Log in
      </Button>
    </>
  )}
</Form>

{status && (
  <div className="mb-4 text-center text-sm font-medium text-green-600">
    {status}
  </div>
)}

このような形になっている。ここで

<Form {...store.form()}>

はjsのスプレット構文を使いオブジェクトの中身を展開して渡すということで、おそらく、この機能がまだ確立していなかった頃の書き方だろう。従って以下でも「大体」同じになる。

<Form action={store()}>

ただし下の方式で展開されるのはactionとmethodだけなので違う挙動をするため、わざわざ全部書き方を置換する程では無いだろうし、それをやると動作保証はできないかもしれない。ここでは大体理解しておけばok。


続いて resources/js/pages/settings/profile.tsx を見てみよう

resources/js/pages/settings/profile.tsx(抜粋)
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { send } from '@/routes/verification';
import { edit } from '@/routes/profile';

const breadcrumbs: BreadcrumbItem[] = [
    {
        title: 'Profile settings',
        href: edit().url,
    },
];

<Form
  {...ProfileController.update.form()}
  options={{
    preserveScroll: true,
  }}
  className="space-y-6"
>
  {({ processing, recentlySuccessful, errors }) => (
    <>
      {mustVerifyEmail &&
        auth.user.email_verified_at === null && (
          <div>
            <p className="-mt-4 text-sm text-muted-foreground">
              Your email address is unverified.{` `}
              <Link
                href={send()}
                as="button"
                className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
              >
                Click here to resend the verification email.
              </Link>
            </p>
          </div>
        )}

      <div className="flex items-center gap-4">
        <Button
          disabled={processing}
          data-test="update-profile-button"
        >
          Save
        </Button>
      </div>
    </>
  )}
</Form>

ここで

import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';

という構文が出てくるが、これはコントローラーから直で注入する方式だ。今まで扱ってこなかったけど、こういうのもある。

その一方で

import { send } from '@/routes/verification';
// 
<Link href={send()}

みたいなのも混ざっており、混沌としている。さらにbreadcrumbが

const breadcrumbs: BreadcrumbItem[] = [
    {
        title: 'Profile settings',
        href: edit().url,
    },
];

やや混沌としているが、breadcrumbはこれでいいだろう、多分

で、何が言いたいかというとコントローラー注入式とか、ルートインポート式とかがスターターキットの書き方が結構統一されていないという事である

問題なりえるのはAI生成式プログラミングをやる場合

AIプログラミングとなると新しいライブラリーの書き方は大抵知らないので他のコードを雑に参照させると暴走する事があるようだ。で、ある程度人間側にも前提の知識が無いと暴走している事にすら気付かないというとんでもないことになる可能性が否定できない。この辺は十分注意して、最低限の知識を獲得した後で、AIにそのように書くように仕向けてやるようにする必要があるはずだ。

特にスターターキットで展開されているものは過渡期のままアップデートされていないものも見られるため、ある程度指針となるコードを作っておいて、必要に応じて参照用コードを用意しておくとよいだろう。

Discussion