【最新版】Inertia.jsにおけるPartial Reload (2025年1月初頭)
ユーザーを会社で絞りこむインターフェース
ドキュメントに従って、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は使ってないし、useForm
のsetData
を使わずとも実装できる
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の時刻を表示してみよう。
こんな感じで時刻が出てくる。
useForm
のget
の場合
このように時間が変化するが、router.reload
の場合
stateに変化がない。
このように挙動に違いがあるため設計には気をつけて行う必要があるかもしれない。
データー評価のタイミング
ここに事例があって
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
に変更されている
まあそれはさておいて、この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