inertia.jsのpartial reloadを(今んとこ)世界一詳しくコード付きで解説するドキュメント
誰得なんだよこれ…
ここにある説明じゃ全然わからんから手を動かしてみようという企画
準備
まず、ここでは会社一覧と、ユーザー一覧を出すページを想定する。
つまりusersとcompaniesのためのtableを用意する。これらは非常に適当でいいが、数が必要である。
Companyの準備
とりあえずcompaniesテーブルの構造
Schema::create('companies', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
まあこんな感じだろう。で、seedする
database/seeders/DatabaseSeeder.php
\App\Models\Company::factory(10)->create();
などとしておき、factoryを作る
database/factories/CompanyFactory.php
public function definition(): array
{
return [
'name' => fake()->company,
];
}
このfactoryを元に10個seedしているので以下のような10個ダミー会社が出来るはずである(fakerのlocaleは日本語であるとする、場合によっては英語になってるかもしれんがここでは解説しない)
> Company::get(['id', 'name'])
= Illuminate\Database\Eloquent\Collection {#7336
all: [
App\Models\Company {#7328
id: 1,
name: "有限会社 渡辺",
},
App\Models\Company {#7327
id: 2,
name: "有限会社 小泉",
},
App\Models\Company {#7324
id: 3,
name: "有限会社 田中",
},
App\Models\Company {#7310
id: 4,
name: "株式会社 大垣",
},
App\Models\Company {#7322
id: 5,
name: "有限会社 井上",
},
App\Models\Company {#7321
id: 6,
name: "有限会社 佐々木",
},
App\Models\Company {#7343
id: 7,
name: "有限会社 村山",
},
App\Models\Company {#7344
id: 8,
name: "有限会社 大垣",
},
App\Models\Company {#7342
id: 9,
name: "有限会社 石田",
},
App\Models\Company {#7358
id: 10,
name: "有限会社 木村",
},
],
}
userの準備
そしたらユーザーのファクトリーも適当にcompanyから割り当てるようにしよう。ここではcompany_idというカラムを供えるものとする
return [
'company_id' => Company::inRandomOrder()->first()->id,
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
'remember_token' => Str::random(10),
];
前準備はこれでok。
ユーザーを一覧するjsx
まあここでは本格的にユーザー管理をするわけじゃないがとりあえず
artisan make:controller UserController -m User -r
このようにUserController の作成が必要だろう。routes/web.phpには適当にresourceを当てておく
Route::resource('users', UserController::class);
ここまでで/users/までのルートが出来らので、一覧を書いていく
app/Http/Controllers/UserController.php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use App\Models\User;
use App\Models\Company;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class UserController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(): Response
{
$users = User::latest()->get();
$companies = Company::latest()->get();
return Inertia::render('Users/Index', [
'users' => $users,
'companies' => $companies,
]);
}
そしたらInertia::renderで指定されているように、viewを書く。こんな感じだろう
resources/js/Pages/Users/Index.jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';
export default function UserIndex({ auth, users, companies }) {
return (
<AuthenticatedLayout
user={auth.user}
header={
<h2 className="font-semibold text-xl text-gray-800 leading-tight">
Users
</h2>
}
>
<Head title="Users" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
{/* ユーザーテーブルの追加 */}
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.id}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.email}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.name}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
まあここはfakerで適当に作られているから、内容は適当なものとなるが、10人程度出ていることが重要である。
会社情報も出す
適当にササーっとrelationを組む
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class User extends Authenticatable
{
use SoftDeletes, HasApiTokens, HasFactory, Notifiable;
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
controllerもinertiaの場合いきなりオブジェクトを注入ってわけにはいかないので事前ロードする。ここではwithを使う。
public function index(): Response
{
$users = User::with('company')->latest()->get();
$companies = Company::latest()->get();
return Inertia::render('Users/Index', [
'users' => $users,
'companies' => $companies,
]);
}
でまあこれに応じて適当にCompany列を増やす
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Company
</th>
...
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.company.name}</td>
ここまでが前準備、といいたいところだがCompanyの一覧をまだ活用できていない。ここではPluckする
$users = User::with('company')->latest()->get();
$companies = Company::pluck('name', 'id');
return Inertia::render('Users/Index', [
'users' => $users,
'companies' => $companies,
]);
そしたら
<div className="m-4 flex items-center">
<select className="form-select appearance-none block w-full px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding bg-no-repeat border border-solid border-gray-300 rounded transition ease-in-out focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none">
{Object.entries(companies).map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
{/* フィルタリング用のボタン */}
<button className="ml-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded focus:outline-none focus:shadow-outline">
Filter
</button>
</div>
こんなのを付けておく
ここまでで準備完了
filterボタンを押すととりあえずreloadするようにする
filterなのにreloadはしないw、つか、ボタンのclassがなげえからbreezeのPrimaryButtonを使おうか…
import PrimaryButton from '@/Components/PrimaryButton'
...
<PrimaryButton onClick={handleFilterClick}>
Filter
</PrimaryButton>
そうしたら、handleFilterClick 関数を書く
window.location.reloadの場合
export default function UserIndex({ auth, users, companies }) {
const handleFilterClick = () => {
window.location.reload();
};
たとえば、こういうwindow.location.reloadの場合、ブラウザのF5を押したような挙動になる。というかこれが通常の文字通りのリロードというやつだろう。
inertiaのrouter.reload
が、これだとinertiaっぽくないので、inertiaのrouterをもってくる。これに関しては以下のドキュメントも合わせて参照して欲しい
とよいが、まあここではさくっと書いておく
import { router } from '@inertiajs/react'
export default function UserIndex({ auth, users, companies }) {
const handleFilterClick = () => {
router.reload();
};
この状態だとページの動きがほとんど無いのがわかるだろう。
しかし、これだと面白くないから、もうちょっとcontrollerに手を入れる
public function index(): Response
{
// 全ユーザーを取得し、その数を確認
$allUsers = User::with('company')->latest()->get();
$userCount = $allUsers->count();
// ランダムな数(1〜ユーザー総数)のユーザーを取得
$users = $allUsers->random(rand(1, $userCount));
$companies = Company::pluck('name', 'id');
return Inertia::render('Users/Index', [
'users' => $users,
'companies' => $companies,
]);
}
コードはどういう風でもいいけど、このようにusersに対してランダム性のある配列にしたのがポイントだ。
そうすると…ボタンを押すたびにランダムなユーザー列が取得されてくる。これもまたページのリフレッシュが発生していないことが理解可能である、はず。
Partial Reloadの考え
前段が非常に長くなったが、ここからがPartial Reload。
さて、この際に実は「Company」のモデルには何も影響をおよぼしていない、そういう事はまあまあある。usersの配列の内容だけ変化するというような時だ、このようなとき「usersだけ変化させればいいんじゃないの?」というのがあるが、実際には当然ボタンを押すと両方にクエリが発行されている。これをまず確認してみよう
現状のクエリの確認
public function index(): Response
{
// クエリログを有効にする
\DB::enableQueryLog();
// 全ユーザーを取得し、その数を確認
$allUsers = User::with('company')->latest()->get();
$userCount = $allUsers->count();
// ランダムな数(1〜ユーザー総数)のユーザーを取得
$users = $allUsers->random(rand(1, $userCount));
$companies = Company::pluck('name', 'id');
// クエリログを取得
$queryLog = \DB::getQueryLog();
// クエリログをLaravelのログに記録
\Log::info('Query Log:', $queryLog);
return Inertia::render('Users/Index', [
'users' => $users,
'companies' => $companies,
]);
}
こんな風にしておくとlaravel.logにはこんな風に出てくるだろう、ボタンを押すたびに
[2023-12-19 03:14:57] local.INFO: Query Log: [{"query":"select * from `users` where `users`.`deleted_at` is null order by `created_at` desc","bindings":[],"time":0.54},{"query":"select * from `companies` where `companies`.`id` in (1)","bindings":[],"time":0.48},{"query":"select `name`, `id` from `companies`","bindings":[],"time":0.38}]
[2023-12-19 03:15:37] local.INFO: Query Log: [{"query":"select * from `users` where `users`.`deleted_at` is null order by `created_at` desc","bindings":[],"time":0.6},{"query":"select * from `companies` where `companies`.`id` in (1)","bindings":[],"time":0.45},{"query":"select `name`, `id` from `companies`","bindings":[],"time":0.42}]
これは
select * from `users`
select * from `companies`
とかでわかるようにusersとcompaniesに対してクエリーが発行されている(ただし、もちろんusersに関連したcompanyを取得するクエリーもあるので実は3つあるが)
Partial Reloadしてみる
このドキュメントのスニペットに以下のようなものがある
import { router } from '@inertiajs/react'
router.reload({ only: ['users'] })
これをやってみよう
export default function UserIndex({ auth, users, companies }) {
const handleFilterClick = () => {
// window.location.reload();
// router.reload();
router.reload({ only: ['users'] })
};
これは単純にreloadする際にusersだけを求めるという機能になる。ただ、現在のコントローラーのコードを改めて見てみると
// クエリログを有効にする
\DB::enableQueryLog();
// 全ユーザーを取得し、その数を確認
$allUsers = User::with('company')->latest()->get();
$userCount = $allUsers->count();
// ランダムな数(1〜ユーザー総数)のユーザーを取得
$users = $allUsers->random(rand(1, $userCount));
$companies = Company::pluck('name', 'id');
// クエリログを取得
$queryLog = \DB::getQueryLog();
// クエリログをLaravelのログに記録
\Log::info('Query Log:', $queryLog);
return Inertia::render('Users/Index', [
'users' => $users,
'companies' => $companies,
]);
これだと何も発生しない。こういった場合はPartialReloadのリクエストかどうかを判別する必要がある(ドキュメントにはあまり書いてないけど)
Partial Reloadリクエストかどうかの判別
public function index(Request $request): Response
{
dd($request->header('X-Inertia-Partial-Data'));
このような感じでX-Inertia-Partial-Data を参照してみると…
このようにPartial reloadの場合は必要なpropsが表示されいる。これは
ここではusersが表示されている
ということは
public function index(Request $request): Response
{
$partialData = $request->header('X-Inertia-Partial-Data');
// クエリログを有効にする
\DB::enableQueryLog();
// 全ユーザーを取得し、その数を確認
$allUsers = User::with('company')->latest()->get();
$userCount = $allUsers->count();
// ランダムな数(1〜ユーザー総数)のユーザーを取得
$users = $allUsers->random(rand(1, $userCount));
if ($partialData) {
// クエリログを取得
$queryLog = \DB::getQueryLog();
// クエリログをLaravelのログに記録
\Log::info('Partial query Log:', $queryLog);
return Inertia::render('Users/Index', [
'users' => $users,
]);
} else {
$companies = Company::pluck('name', 'id');
// クエリログを取得
$queryLog = \DB::getQueryLog();
// クエリログをLaravelのログに記録
\Log::info('Query Log:', $queryLog);
return Inertia::render('Users/Index', [
'users' => $users,
'companies' => $companies,
]);
}
}
このようにpartialDataがあるときは
return Inertia::render('Users/Index', [
'users' => $users,
]);
このようにcompaniesを取得せずusersだけ返却する、これで動作する
動作する、が、
この程度のためにこんなコードを複雑にする必要は通常ありえないだろう。
で、クエリーログをコントローラーのコードに紛れこませると面倒くせえことになるのでリスナーを使う
app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
DB::listen(function ($query) {
// クエリ、バインディング、実行時間をログに記録します
Log::info($query->sql, ['bindings' => $query->bindings, 'time' => $query->time]);
});
}
これは実験的なので本番では使わないように。そうすればコントローラーがちょっとすっりいする
public function index(Request $request): Response
{
$partialData = $request->header('X-Inertia-Partial-Data');
// 全ユーザーを取得し、その数を確認
$allUsers = User::with('company')->latest()->get();
$userCount = $allUsers->count();
// ランダムな数(1〜ユーザー総数)のユーザーを取得
$users = $allUsers->random(rand(1, $userCount));
if ($partialData) {
return Inertia::render('Users/Index', [
'users' => $users,
]);
} else {
$companies = Company::pluck('name', 'id');
return Inertia::render('Users/Index', [
'users' => $users,
'companies' => $companies,
]);
}
}
さて、この状態で条件分岐が非常にうっとうしいだろう。これを無くしてしまうとするとどうか
public function index(Request $request): Response
{
$partialData = $request->header('X-Inertia-Partial-Data');
// 全ユーザーを取得し、その数を確認
$allUsers = User::with('company')->latest()->get();
$userCount = $allUsers->count();
// ランダムな数(1〜ユーザー総数)のユーザーを取得
$users = $allUsers->random(rand(1, $userCount));
$data = [
'users' => $users,
'companies' => $partialData ? null : Company::pluck('name', 'id'),
];
return Inertia::render('Users/Index', $data);
}
partialDataがあるときは、companies、nullを返却するようになった。
さて、ここで初回のクエリーを見てみよう。
[2023-12-20 08:14:20] local.INFO: select * from `users` where `id` = ? and `users`.`deleted_at` is null limit 1 {"bindings":[11],"time":2.28}
[2023-12-20 08:14:20] local.INFO: select * from `users` where `users`.`deleted_at` is null order by `created_at` desc {"bindings":[],"time":0.68}
[2023-12-20 08:14:20] local.INFO: select * from `companies` where `companies`.`id` in (1) {"bindings":[],"time":0.5}
[2023-12-20 08:14:20] local.INFO: select `name`, `id` from `companies` {"bindings":[],"time":0.58}
続いてpartial reloadしたときのクエリをみてみる
[2023-12-20 08:15:12] local.INFO: select * from `users` where `id` = ? and `users`.`deleted_at` is null limit 1 {"bindings":[11],"time":2.63}
[2023-12-20 08:15:12] local.INFO: select * from `users` where `users`.`deleted_at` is null order by `created_at` desc {"bindings":[],"time":0.64}
[2023-12-20 08:15:12] local.INFO: select * from `companies` where `companies`.`id` in (1) {"bindings":[],"time":0.52}
ここでの違いで重要なのは
select `name`, `id` from `companies`
このクエリは2回目のpartial reloadでは含まれていないことだ。まあ三項演算子で省いているので当然といえば当然であるが
さらに改造する
public function index(): Response
{
// 全ユーザーを取得し、その数を確認
$allUsers = User::with('company')->latest()->get();
$userCount = $allUsers->count();
// ランダムな数(1〜ユーザー総数)のユーザーを取得
$users = $allUsers->random(rand(1, $userCount));
$data = [
'users' => $users,
'companies' => fn () => Company::pluck('name', 'id'),
];
return Inertia::render('Users/Index', $data);
}
このようにヘッダーを見るのをやめ
$data = [
'users' => $users,
'companies' => fn () => Company::pluck('name', 'id'),
];
ここでcompaniesに対して遅延データー評価を発動することでcompaniesは初回のみロードされることになる。
てか、なかなか難しいよねこの概念。手を動かさないとわからないかもしれないね。
partial reloadを使ったvisit
たとえば、今indexはusersとcompaniesを律儀に返却しているが、usersだけ返却するコントローラーを作ってもいい。ほぼフルコピーでpartialという名前のメソッドを作ろう。
public function partial(Request $request): Response
{
// 全ユーザーを取得し、その数を確認
$allUsers = User::with('company')->latest()->get();
$userCount = $allUsers->count();
// ランダムな数(1〜ユーザー総数)のユーザーを取得
$users = $allUsers->random(rand(1, $userCount));
return Inertia::render('Users/Index', ['users' => $users]);
}
これはもうcompaniesを渡してすらおらずusers=>$usersしか発動していない。
そしたらこんな風にrouteを立てて
Route::get('users/partial', [UserController::class, 'partial'])->name('users.partial');
Route::resource('users', UserController::class);
ここに向かってvisitしてみると
const handleFilterClick = () => {
// window.location.reload();
// router.reload();
// router.reload({ only: ['users'] })
router.visit(route('users.partial'), { only: ['users'] })
};
これは正しく動作する、が、問題がある。この場合/users/partialに文字通りvisitしてしまうのでurlが/users/partialになっている。この状態でブラウザーのリロードをすると当然
public function partial(Request $request): Response
{
// 全ユーザーを取得し、その数を確認
$allUsers = User::with('company')->latest()->get();
$userCount = $allUsers->count();
// ランダムな数(1〜ユーザー総数)のユーザーを取得
$users = $allUsers->random(rand(1, $userCount));
return Inertia::render('Users/Index', ['users' => $users]);
}
このコードが評価される、つまり、companiesが無いのでエラーとなるわけだ。このように、partial reloadを使う場合はそのinertiaの特性と戦いながら使い方をよく考えないといけない
reloadとvisitでの重要な違い
stateを用意してみよう。一度reloadとかvisitとかは削除しておく
import React, { useState } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';
import { router } from '@inertiajs/react'
import PrimaryButton from '@/Components/PrimaryButton'
export default function UserIndex({ auth, users, companies }) {
const [isFiltered, setIsFiltered] = useState(false);
const handleFilterClick = () => {
setIsFiltered(!isFiltered); // 状態を反転させる
// window.location.reload();
// router.reload();
// router.reload({ only: ['users'] })
// router.visit(route('users.partial'), { only: ['users'] })
};
これに基いてボタンを少し変更する
<PrimaryButton onClick={handleFilterClick} className="mr-2">
Filter
</PrimaryButton>
{isFiltered ? (
<span className="inline-block bg-blue-500 text-white text-xs px-2 rounded-full uppercase font-semibold tracking-wide">
1
</span>
) : (
<span className="inline-block bg-gray-300 text-gray-700 text-xs px-2 rounded-full uppercase font-semibold tracking-wide">
2
</span>
)}
つまり、このよう1だの2だのがstateにより変化するはずだ
まあこれはpartial reloadまったく関係なくreactの機能でしかないんだけど、これを使う場合注意必要である
router.reloadを使う場合
const handleFilterClick = () => {
setIsFiltered(!isFiltered); // 状態を反転させる
// window.location.reload();
// router.reload();
router.reload({ only: ['users'] })
// router.visit(route('users.partial'), { only: ['users'] })
};
これはrouter.reloadを使った例で(onlyはあってもなくてもいいけど)とにかく、この状態だとstateは完全に維持される。stateのボタンがtoggleしながらtableの内容が変化しているのが理解できるだろう
visitの場合
おなじ条件でちょっとコードを変化させてみよう
const handleFilterClick = () => {
setIsFiltered(!isFiltered); // 状態を反転させる
// window.location.reload();
// router.reload();
// router.reload({ only: ['users'] })
router.visit(route('users.partial'), { only: ['users'] })
};
わかりますか?visitはそれを行うごとにstateがリセットされる。だからstateをキープしたまま一部のページリロードを行いたい場合はvisitは使えない
最後にchatgptを用いてドキュメントの訳っぽいものを載せていこう
ここまで踏まえた上で最後公式のわずかなドキュメントを翻訳し、解説を付けていく
訳注: まあこれは実際に今まで長々と解説してきたような事を言っている。当然同じコンポーネントでしか使えないのだが、解説してこなかった所だったので一部太字にした
import { router } from '@inertiajs/react'
router.visit(url, {
only: ['users'],
})
訳注: 最初にvisitを紹介している割には二段目ではreloadの方がいいぞと言っているのが面白いんだけどstateとかの関係もあり、どう考えてもreloadで設計する方が正しい、となるとここには書いてないがbackendは大抵同一コントローラー同一メソッドで処理する事になるだろう
import { Link } from '@inertiajs/react'
<Link href="/users?active=true" only={['users']}>Show active</Link>
訳注: 使い所は難しそうな気もするが、たとえばpagerとかのfilterだろうか
return Inertia::render('Users/Index', [
// 初回訪問時に常に含まれる...
// パーシャルリロード時にオプションで含まれる...
// 常に評価される...
'users' => User::get(),
// 初回訪問時に常に含まれる...
// パーシャルリロード時にオプションで含まれる...
// 必要時にのみ評価される...
'users' => fn () => User::get(),
// 初回訪問時には決して含まれない...
// パーシャルリロード時にオプションで含まれる...
// 必要時にのみ評価される...
'users' => Inertia::lazy(fn () => User::get()),
]);
訳注: ここでは2番目の例を利用した。いずれにせよ、partial reloadを使う場合はrenderの第二引数が重要になるからそこをちゃんと意識しておく。
まとめ
全体的にドキュメントも少なくスニペットもほとんどweb上にないので「現状世界一」としている。ただ、この例だとイマイチ使い所も微妙でありパフォーマンスとか言われてもそんなもんcompanyごとき全部取得したらいいんじゃないの?と思ってしまうね。次回はこの機能を使ったapiっぽい振舞いを考えてみよう。inertia.jsの場合はapiコールっぽいことをし辛いので、どうしてもこの機能をベースに設計していくことになりがちである。
この記事が気に入ったらサポートをしてみませんか?