📖

【最新版】Inertia.jsにおけるPartial Reload (2025年1月初頭)

2025/01/04に公開

https://inertiajs.com/partial-reloads

ユーザーを会社で絞りこむインターフェース

ドキュメントに従って、Inertia.jsでこのようなインターフェースを考えたとき、どうやって実装するかを考える。

このボタンを押したときに

usersが更新されて再描画されれば要件は満たせるはずだ。

Inertia.jsの場合、リクエストを出す方法は2つくらいあるのだが、このようにフォームっぽい動作を期待する場合はまずuseFormを使ってみる事にする。これは非常に古典的なhtmlっぽい記述になる。やってみよう。

import PrimaryButton from '@/Components/PrimaryButton';
import DemoLayout from '@/Layouts/DemoLayout';
import { Head, useForm } from '@inertiajs/react';

export default function PartialReloadDemo({ users, companies }) {
  const { data, setData, post, processing, errors } = useForm({
    company_id: '',
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    post(route('partial-reload-filter'));
  };

  return (
    <DemoLayout
      header={
        <h2 className="text-xl font-semibold leading-tight text-gray-800">
          User List with Company Filter
        </h2>
      }
    >
      <Head title="User List with Filter" />
      <div className="mx-auto max-w-7xl space-y-6 px-4 sm:px-6 lg:px-8">
        <div className="bg-white p-6 shadow sm:rounded-lg">
          <h3 className="text-lg font-medium leading-6 text-gray-900">
            Filter by Company
          </h3>
          <form onSubmit={handleSubmit}>
            <div className="mt-4 flex items-center gap-4">
              <select
                value={data.company_id}
                onChange={(e) => setData('company_id', e.target.value)}
                className="w-1/2 rounded border px-4 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
              >
                <option value="">Select a company</option>
                {Object.entries(companies).map(([id, name]) => (
                    <option key={id} value={id}>
                        {name}
                    </option>
                ))}
              </select>
              <PrimaryButton type="submit" disabled={processing}>
                {processing ? 'Applying...' : 'Apply Filter'}
              </PrimaryButton>
            </div>
            {errors.company_id && (
              <p className="mt-2 text-sm text-red-600">{errors.company_id}</p>
            )}
          </form>
        </div>

        <div className="bg-white shadow sm:rounded-lg">
          <div className="p-6">
            <h3 className="text-lg font-medium leading-6 text-gray-900">
              Registered Users
            </h3>
            <ul className="divide-y divide-gray-200">
              {users.map((user) => (
                <li key={user.id} className="flex items-center py-4">
                  <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 font-bold text-white">
                    {user.name.charAt(0).toUpperCase()}
                  </div>
                  <div className="ml-4">
                    <p className="text-sm font-medium text-gray-900">
                      {user.name}
                    </p>
                    <p className="text-sm text-gray-500">ID: {user.id}</p>
                    <p className="mt-1">
                      <span
                        className={`inline-block rounded-full px-3 py-1 text-sm font-semibold ${
                          user.company
                            ? 'bg-green-100 text-green-800'
                            : 'bg-gray-100 text-gray-800'
                        }`}
                      >
                        {user.company ? user.company.name : 'No Company'}
                      </span>
                    </p>
                  </div>
                </li>
              ))}
            </ul>
          </div>
        </div>
      </div>
    </DemoLayout>
  );
}

重要な所だけピックアップすると

          <form onSubmit={handleSubmit}>
              <select
                value={data.company_id}
                onChange={(e) => setData('company_id', e.target.value)}
              >
                <option value="">Select a company</option>
                {Object.entries(companies).map(([id, name]) => (
                  <option key={company.id} value={company.id}>
                    {company.name}
                  </option>
                ))}
              </select>
              <PrimaryButton type="submit" disabled={processing}>
                {processing ? 'Applying...' : 'Apply Filter'}
              </PrimaryButton>
            </div>
          </form>

こんな感じになっている。この時

  const handleSubmit = (e) => {
    e.preventDefault();
    post(route('partial-reload-filter'));
  };

こんな感じでpartial-reload-filterルートに飛ぶのでルートを作って

routes/web.php

Route::get('/partial-reload', [PartialReloadController::class, 'index'])->name('partial-reload');
Route::post('/partial-reload/filter', [PartialReloadController::class, 'filter'])->name('partial-reload-filter')
    public function filter(Request $request): Response
    {
        dd($request->all());
    }

このようにリクエストダンプすると

こんな感じでidが取れる。

そしたら、フィルターして全てコピペで書き直してあげれば動くんじゃないかという事になるが

app/Http/Controllers/PartialReloadController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use App\Models\User;
use App\Models\Company;

class PartialReloadController extends Controller
{
    public function index(Request $request): Response
    {
        $users = User::with('company')->get();
        $companies = Company::pluck('name', 'id');

        return Inertia::render('PartialReload', [
            'users' => $users,
            'companies' => $companies,
        ]);
    }

    public function filter(Request $request): Response
    {
        $companyId = $request->input('company_id');

        $users = $companyId
            ? User::where('company_id', $companyId)->with('company')->get()
            : User::with('company')->get();

        $companies = Company::pluck('name', 'id');

        return Inertia::render('PartialReload', [
            'users' => $users,
            'companies' => $companies,
        ]);
    }
}

まあただこれは流石にやりたくないよねって話から本稿は初まる。

同一ルート(index)で処理してみる

まず、indexとfilterの2つのmethodがコード的にも重複するので普通に考える場合、まずindexで全部やるって事を考えるんじゃなかろうか。これはそんな難しくないはずだ。

--- a/resources/js/Pages/PartialReload.jsx
+++ b/resources/js/Pages/PartialReload.jsx
@@ -3,13 +3,13 @@ import DemoLayout from '@/Layouts/DemoLayout';
 import { Head, useForm } from '@inertiajs/react';

 export default function PartialReloadDemo({ users, companies }) {
-  const { data, setData, post, processing, errors } = useForm({
+  const { data, setData, get, processing, errors } = useForm({
     company_id: '',
   });

   const handleSubmit = (e) => {
     e.preventDefault();
-    post(route('partial-reload-filter'));
+    get(route('partial-reload'));
   };

   return (

backendはこのように改善できる

--- a/app/Http/Controllers/PartialReloadController.php
+++ b/app/Http/Controllers/PartialReloadController.php
@@ -12,7 +12,11 @@ class PartialReloadController extends Controller
 {
     public function index(Request $request): Response
     {
-        $users = User::with('company')->get();
+        $companyId = $request->input('company_id');
+        $users = $companyId
+            ? User::where('company_id', $companyId)->with('company')->get()
+            : User::with('company')->get();
+
         $companies = Company::pluck('name', 'id');

選択が外れてしまう問題

このようにfilterを押したときに選択した会社にselectboxが留まって欲しい時があるが、今回はそうなっていない。これを修正する

--- a/app/Http/Controllers/PartialReloadController.php
+++ b/app/Http/Controllers/PartialReloadController.php
@@ -12,12 +12,17 @@ class PartialReloadController extends Controller
 {
     public function index(Request $request): Response
     {
-        $users = User::with('company')->get();
+        $companyId = $request->input('company_id');
+        $users = $companyId
+            ? User::where('company_id', $companyId)->with('company')->get()
+            : User::with('company')->get();
+
         $companies = Company::pluck('name', 'id');

         return Inertia::render('PartialReload', [
             'users' => $users,
             'companies' => $companies,
+            'selectedCompanyId' => $companyId,
         ]);
     }

このようにselectedCompanyIdを与えて

--- a/resources/js/Pages/PartialReload.jsx
+++ b/resources/js/Pages/PartialReload.jsx
@@ -2,14 +2,15 @@ import PrimaryButton from '@/Components/PrimaryButton';
 import DemoLayout from '@/Layouts/DemoLayout';
 import { Head, useForm } from '@inertiajs/react';

-export default function PartialReloadDemo({ users, companies }) {
-  const { data, setData, post, processing, errors } = useForm({
-    company_id: '',
+export default function PartialReloadDemo({ users, companies, selectedCompanyId }) {
+  const { data, setData, get, processing, errors } = useForm({
+    company_id: selectedCompanyId || '',
   });

このような形でdefault値をセット。これでフィルターできるようにはなった

Partial Reloads (ここからが本題)

ここまではPartial Reloads何も関係ないので、ここから実装していく

まず、Partial Reloadsという名前がアレなんだけどreloadに限らず基本的にはどういったリクエストでも使えたりする。ここでは結局GETで自分自身を取得しているのでリロードとなるのだが、いずれにせよ、このような構文になる

get("リンク先", { キー: "値" });

ここで{ キー: "値" }がオプションとなる。

Partial Reloadsのオプションはonlyを必ず使う

まず試しに以下のようにしてみよう

  const handleSubmit = (e) => {
    e.preventDefault();
    get(route('partial-reload'), { only: ['users'] });
  };

これでusersのみ取得されるはずだ。やってみよう

このようにユーザーリストは更新されたが、company_idが送信されておらず、セレクターが検索対象へselectedされるやつが効かなくなってdefaultに戻されている。これは単純な話で、usersプロップスのみを更新対象として指示した為、selectedCompanyIdが更新されていないだけだ。

これは、要するにPartial Reloadsが効いている事の証左となるのだが、これだと厳しいので、selectedCompanyIdも更新するように含めてみよう。

   const handleSubmit = (e) => {
     e.preventDefault();
-    get(route('partial-reload'));
+    get(route('partial-reload'), { only: ['users', 'selectedCompanyId'] });
   };

このように正しく動作するようになる。

Inertia::always()

これは要するにfrontendで要求するかbackendで送りつけるかの違いなのだがたとえば

get(route('partial-reload'), { only: ['users', 'selectedCompanyId'] });

とか書きたくない場合

get(route('partial-reload'), { only: ['users'] });

で済ませたい場合はControllerで

    public function index(Request $request): Response
    {
        $companyId = $request->input('company_id');
        $users = $companyId
            ? User::where('company_id', $companyId)->with('company')->get()
            : User::with('company')->get();

        $companies = Company::pluck('name', 'id');

        return Inertia::render('PartialReload', [
            'users' => $users,
            'companies' => $companies,
            'selectedCompanyId' => Inertia::always($companyId),
        ]);
    }

このようにInertia::always($companyId)とすればよい。ただし、これをするとどっちが優先されているのかわからなくなって混乱を招く可能性もあるので、可能ならこれを使わずフロントエンドで利用するpropsを指定してあげたいように思える(個人的に)。

Controllerでも必要なものだけ渡す改造

結局要求しているのがusersとselectedCompanyIdだけなので、この2つだけ返すようにしても動作する。これは今どのpropsを要求しているのかヘッダで取る事ができる。

  const handleSubmit = (e) => {
    e.preventDefault();
    get(route('partial-reload'), { only: ['users', 'selectedCompanyId'] });
  };

このように戻して、送信したときbackendで

    public function index(Request $request): Response
    {
        $companyId = $request->input('company_id');
        if ($companyId) {
            dd($request->header('X-Inertia-Partial-Data'));
        }
        $users = $companyId
            ? User::where('company_id', $companyId)->with('company')->get()
            : User::with('company')->get();

        $companies = Company::pluck('name', 'id');

        return Inertia::render('PartialReload', [
            'users' => $users,
            'companies' => $companies,
            'selectedCompanyId' => Inertia::always($companyId),
        ]);
    }

すると

となる。これに応じて

    public function index(Request $request): Response
    {
        $companyId = $request->input('company_id');
        $partialData = $request->header('X-Inertia-Partial-Data');
        $requestedData = $partialData ? explode(',', $partialData) : [];

        $users = $companyId
            ? User::where('company_id', $companyId)->with('company')->get()
            : User::with('company')->get();

        if (in_array('users', $requestedData)) {
            return Inertia::render('PartialReload', [
                'users' => $users,
                'selectedCompanyId' => $companyId,
            ]);
        }

        $companies = Company::pluck('name', 'id');
        return Inertia::render('PartialReload', [
            'users' => $users,
            'companies' => $companies,
            'selectedCompanyId' => $companyId,
        ]);
    }

などとしてもいい(けどコードが冗長やね)

別アクションに飛ばす方法

routes/web.php

Route::get('/load-when-visible', LoadWhenVisibleController::class)->name('load-when-visible');
Route::get('/partial-reload', [PartialReloadController::class, 'index'])->name('partial-reload');
Route::get('/partial-reload/filter', [PartialReloadController::class, 'filter'])->name('partial-reload-filter');

ここで冒頭でやっていたものをgetに変更した。その後get先をこのroute名に変更して

  const handleSubmit = (e) => {
    e.preventDefault();
    get(route('partial-reload-filter'), {
      only: ['users', 'selectedCompanyId'],
    });
  };
    public function filter(Request $request): Response
    {
        $companyId = $request->input('company_id');

        $users = $companyId
            ? User::where('company_id', $companyId)->with('company')->get()
            : User::with('company')->get();

        return Inertia::render('PartialReload', [
            'users' => $users,
            'selectedCompanyId' => $companyId,
        ]);
    }

このようにしても動作はする。ただ、/partial-reload/filter?company_id=4 のようにurlが変化してしまうため非常に都合がよくない。POSTならわりとうまいこと動作するのでGETリクエストではちょっとpending案件ではある。

router.reloadを使う方法

useFormではなくrouter.reloadを使う事もできる。まず、

--- a/resources/js/Pages/PartialReload.jsx
+++ b/resources/js/Pages/PartialReload.jsx
@@ -1,14 +1,25 @@
 import PrimaryButton from '@/Components/PrimaryButton';
 import DemoLayout from '@/Layouts/DemoLayout';
-import { Head, useForm } from '@inertiajs/react';
+import { Head, useForm, router } from '@inertiajs/react';

routerを呼びこんで次に

-              <PrimaryButton type="submit" disabled={processing}>
+              <PrimaryButton onClick={handleFilter}>Apply Filter</PrimaryButton>

submitをやめてhandleFilterを参照するようにして、dataの内容をそのままreloadする

  const handleFilter = (e) => {
    e.preventDefault();
    router.reload({
      data: { company_id: data.company_id },
      only: ['users', 'selectedCompanyId'],
    });
  };

この時formは使ってないし、useFormsetDataを使わずとも実装できる

reload()したときとuseFormのget()とかした時の違い

reloadはstateが維持される。たとえば

  const [currentTime, setCurrentTime] = useState(
    new Date().toLocaleTimeString(),
  );

とかして

        <div className="bg-white p-6 shadow sm:rounded-lg">
          <h3 className="text-lg font-medium leading-6 text-gray-900">
            Current Time: <span className="font-semibold">{currentTime}</span>
          </h3>
        </div>

などしてstateの時刻を表示してみよう。

こんな感じで時刻が出てくる。

useFormgetの場合

このように時間が変化するが、router.reloadの場合

stateに変化がない。

このように挙動に違いがあるため設計には気をつけて行う必要があるかもしれない。

データー評価のタイミング

https://inertiajs.com/partial-reloads#lazy-data-evaluation

ここに事例があって

return Inertia::render('Users/Index', [
    // ALWAYS included on standard visits
    // OPTIONALLY included on partial reloads
    // ALWAYS evaluated
    'users' => User::all(),

    // ALWAYS included on standard visits
    // OPTIONALLY included on partial reloads
    // ONLY evaluated when needed
    'users' => fn () => User::all(),

    // NEVER included on standard visits
    // OPTIONALLY included on partial reloads
    // ONLY evaluated when needed
    'users' => Inertia::lazy(fn () => User::all()),

    // ALWAYS included on standard visits
    // ALWAYS included on partial reloads
    // ALWAYS evaluated
    'users' => Inertia::always(User::all()),
]);

このようなスニペットがあるが、実際にはlazyはもはやoptionalに変更されている

https://zenn.dev/catatsumuri/articles/f61a6d4d35fea0

まあそれはさておいて、この4つの違い

Inertia::alwaysは前段で解説済みなのでいいと思う。Inertia::optional(旧Inertia::lazy)はどうだろうか

Inertia::optional

Inertia::optionalはクロージャーを引数に取るので現状だとちょっと面倒なんで関数を分割する。

class PartialReloadController extends Controller
{
    public function index(Request $request): Response
    {
        $companyId = $request->input('company_id');
        $partialData = $request->header('X-Inertia-Partial-Data');
        $requestedData = $partialData ? explode(',', $partialData) : [];

        $companies = Company::pluck('name', 'id');
        return Inertia::render('PartialReload', [
            'users' => $this->getUsers($companyId),
            'companies' => $companies,
            'selectedCompanyId' => $companyId,
        ]);
    }

    protected function getUsers($companyId)
    {
        return $companyId
            ? User::where('company_id', $companyId)->with('company')->get()
            : User::with('company')->get();
    }

このようにしたら、Inertia::optionalしてみよう。そうすると

PartialReload.jsx:87 Uncaught TypeError: Cannot read properties of undefined (reading 'map')

このように、usersが初回のpropに入らなくなるためエラーになる。これはとりあえず初期値を定義しておけば回避できる(もう少し細かくしたい人は是非)

--- a/resources/js/Pages/PartialReload.jsx
+++ b/resources/js/Pages/PartialReload.jsx
@@ -3,7 +3,7 @@ import DemoLayout from '@/Layouts/DemoLayout';
 import { Head, useForm, router } from '@inertiajs/react';
 import { useState } from 'react';

-export default function PartialReloadDemo({ users, companies, selectedCompanyId }) {
+export default function PartialReloadDemo({ users = [], companies, selectedCompanyId }) {

また初回時には一切アクセスされないため、

    protected function getUsers($companyId)
    {
        sleep(2);
        return $companyId
            ? User::where('company_id', $companyId)->with('company')->get()
            : User::with('company')->get();
    }

などとしておいてもfilterされるまではこれは評価されない。

fn () => を使った遅延評価

        return Inertia::render('PartialReload', [
            'users' => Inertia::optional(fn() => $this->getUsers($companyId)),
            // 'companies' => $companies,
            'companies' => fn () => Company::pluck('name', 'id'),
            'selectedCompanyId' => $companyId,
        ]);

このように変更してみよう。するとこれは初回アクセス時は常にpropsを返却するので特に問題にならない。じゃあ何が変わっているのか。

これを確認するためにやはり関数に分割しよう

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use App\Models\User;
use App\Models\Company;
use DB;

class PartialReloadController extends Controller
{
    public function index(Request $request): Response
    {
        $companyId = $request->input('company_id');
        $partialData = $request->header('X-Inertia-Partial-Data');
        $requestedData = $partialData ? explode(',', $partialData) : [];

        return Inertia::render('PartialReload', [
            'users' => Inertia::optional(fn() => $this->getUsers($companyId)),
            'companies' => $this->getCompany(),
            // 'companies' => fn () => $this->getCompany(),
            'selectedCompanyId' => $companyId,
        ]);
    }
    protected function getCompany()
    {
        DB::enableQueryLog();
        $companies = Company::pluck('name', 'id');
        $queryLog = DB::getQueryLog();
        logger('Query Log:', $queryLog);
        return $companies;
    }
/* <snip...> */

このようにCompanyの取得に対しSQLログを採取してみた。まず通常の手法

        return Inertia::render('PartialReload', [
            'users' => Inertia::optional(fn() => $this->getUsers($companyId)),
            'companies' => $this->getCompany(),
            // 'companies' => fn () => $this->getCompany(),
            'selectedCompanyId' => $companyId,
        ]);

だと初回アクセスから3回のfilterを実行するとログは以下の通りとなる

[2025-01-04 02:14:04] local.DEBUG: Query Log: [{"query":"select `name`, `id` from `companies`","bindings":[],"time":1.97}]
[2025-01-04 02:14:12] local.DEBUG: Query Log: [{"query":"select `name`, `id` from `companies`","bindings":[],"time":1.88}]
[2025-01-04 02:14:13] local.DEBUG: Query Log: [{"query":"select `name`, `id` from `companies`","bindings":[],"time":1.88}]
[2025-01-04 02:14:14] local.DEBUG: Query Log: [{"query":"select `name`, `id` from `companies`","bindings":[],"time":1.85}]

次にこのログを削除し、

        return Inertia::render('PartialReload', [
            'users' => Inertia::optional(fn() => $this->getUsers($companyId)),
            // 'companies' => $this->getCompany(),
            'companies' => fn () => $this->getCompany(),
            'selectedCompanyId' => $companyId,
        ]);

こちらで実行してみると

[2025-01-04 02:15:05] local.DEBUG: Query Log: [{"query":"select `name`, `id` from `companies`","bindings":[],"time":1.77}]

初回のみ発動して後はログに記録されていない。この辺が違い

Inertia.version2でのasync化

Partial reloads are now async

Previously partial reloads in Inertia were synchronous, just like all Inertia requests. In v2.0, partial reloads are now asynchronous. Generally this is desireable, but if you were relying on these requests being synchronous, you may need to adjust your code.

とのことです。ドキュメントが膨大になってきたんであとは何とかしてください...

ソース

準備中

Discussion