はじめに
React + Laravelの管理画面で、MUI DataGridのページングとソートをサーバーサイドで実装しました。paginationMode="server" や sortingMode="server" を指定するだけではなく、DataGridの状態をReact Query・URL・Laravelまでつなぐ必要があります。
paginationMode="server" を指定したのにデータが変わらない、DataGridのページ番号とLaravelのページ番号がずれる、DataGridの列名とAPI・DBのカラム名が一致しない、リロード後にページ・ソート状態を復元したい、といった実装時の注意点を中心にまとめます。
全体構成
主な構成は次のとおりです。
フロントエンド(frontend/package.json)
- React
^19.2.0 - TypeScript
~5.9.3 - MUI X Data Grid
^8.28.6 - TanStack Query
^5.99.0 - React Router
^7.13.2
バックエンド(backend/composer.json)
- PHP
^8.3 - Laravel Framework
^13.0
いずれも package.json / composer.json に書かれている要求バージョンです。実行環境で動いている厳密なパッチバージョンまでは確認していません。
最終的なデータフロー
先に、この記事で実装する全体の流れを示します。
初期表示
│
▼
URL query parameters
│
│ page / pageSize / sortBy / sortOrder
▼
paginationModel / sortModel
│
├──────────────► MUI DataGrid
│ │
│ │ ユーザー操作
│◄────────────────────┘
│
├──────────────► URL
│ 状態を保存
│
▼
React Query queryKey
│
▼
userApi
│
│ page / perPage / sortBy / sortOrder
▼
Laravel
│
├─ paginate()
└─ applySort()
│
▼
data / total
│
▼
MUI DataGrid
特に境界部分では、そのまま値を渡すだけではなく変換が入ります。
URL page=0
↓
DataGrid page=0
↓
API page=1
URL sortBy=fullName
↓
DataGrid fullName
↓
API full_name
↓
DB last_name + first_name
同じ page / sortBy という名前でも、URL・DataGrid・APIの層をまたぐと値の起点や表記が変わります。 ここを取り違えないことが、この実装の一番の注意点です。
DataGridをserver modeにする
DataGrid側では、ページングとソートをserver modeにします。
<AppDataGrid
rows={users}
rowCount={total}
paginationMode="server"
paginationModel={paginationModel}
onPaginationModelChange={onPaginationModelChange}
sortingMode="server"
sortModel={sortModel}
onSortModelChange={onSortModelChange}
/>
ページングでは paginationMode="server"、ソートでは sortingMode="server" を指定しています。
これによって、DataGridが自分の持っている rows をページ分割したり並び替えたりしなくなります。ページ送りや列ヘッダーのクリックは onPaginationModelChange / onSortModelChange へ渡され、その状態でどのデータを表示するかはアプリケーション側の担当になります。
ただし、server modeを指定しただけでAPI通信が行われるわけではありません。 この状態をReact Queryへつなぐ必要があります。
paginationModel / sortModelをqueryKeyへ含める
一覧画面ではDataGridの状態をReact Queryへ渡します。
const { data, isLoading, isError, isFetching } = useUsersQuery(
searchCondition, // 検索条件。実装ではリアルタイム検索の分岐が入るが、ここでは省略
paginationModel.page,
paginationModel.pageSize,
sortModel,
);
useUsersQuery では、ページ・ソート条件を queryKey に含めています。
return useQuery({
queryKey: ['users', condition, page, pageSize, sortModel],
queryFn: () =>
userApi.getList(condition, page, pageSize, sortModel),
placeholderData: keepPreviousData,
});
これによって、paginationModel / sortModel が変わる → queryKey が変わる → 新しい条件で queryFn が実行される → APIからデータを取得する、という流れになります。
DataGridとReact Queryが直接連携しているわけではありません。DataGridから受け取った状態を queryKey とAPIリクエストへつなぐことで、サーバーサイド処理を成立させています。
ページ番号の変換:DataGridは0始まり、Laravelは1始まり
DataGridの paginationModel.page は0始まりです。一方、LaravelのPaginatorへ渡す page は、1ページ目を 1 として扱います。
そのため、APIリクエストを組み立てるところで変換しています。
if (page !== undefined) {
params.set('page', String(page + 1));
}
if (pageSize !== undefined) {
params.set('perPage', pageSize.toString());
}
DataGrid page=0
↓
GET /api/users?page=1
DataGrid page=1
↓
GET /api/users?page=2
DataGrid内部の状態をLaravelに合わせるのではなく、APIとの境界で差を吸収します。
Laravel側では paginate() を使います。
$perPage = (int) $request->input('perPage', 10);
$users = $query->paginate($perPage);
return response()->json($users);
LaravelのPaginatorレスポンスには、現在ページのデータだけでなく total などのページング情報も含まれます。フロント側では、この total をDataGridの rowCount に渡します。
<AppDataGrid
rows={users}
rowCount={total}
paginationMode="server"
// ...
/>
server-side paginationでは、現在取得している rows.length だけではデータ全体の件数は分かりません。Laravel側で取得した全件数を rowCount としてDataGridへ伝えることで、DataGrid側がページ数を計算できます。
ソートフィールドの変換:fullName ↔ full_name ↔ last_name + first_name
DataGridから受け取った sortModel もReact Queryを経由してAPIへ渡します。ここで、DataGridの field をそのまま sortBy として送信するのではなく、フロント側でAPI用のフィールド名へ変換しています。
ユーザー一覧の「氏名」列は field: 'fullName'、値は valueGetter で lastName + firstName を組み立てて表示しています。フロントエンド上では fullName ですが、APIでは full_name として扱います。その対応を一か所にまとめています。
const SORT_FIELD_MAP = {
id: 'id',
fullName: 'full_name',
email: 'email',
birthday: 'birthday',
} as const;
この対応表を通して変換した値を、APIリクエストへ使用します。
const apiSortField = userMapper.toApiSortField(
sortModel?.[0]?.field ?? '',
);
if (apiSortField) {
params.set('sortBy', apiSortField);
params.set('sortOrder', sortModel?.[0]?.sort ?? 'asc');
}
この対応表を設けたのは、フロント側で型を安全に扱いながら、同じような変換処理を複数箇所へ散らさないためです。DataGrid上のフィールド名とAPIのフィールド名を分離できます。
DBに存在しないfullNameをソートする
fullName はもう一段変換が必要です。DataGridでは lastName + firstName を一つの「氏名」として表示していますが、DBに full_name という実カラムが存在するわけではありません。
Laravel側では、full_name の場合だけ実際のDB構造に合わせて処理します。
$sortBy = $request->input('sortBy');
$sortOrder = strtolower($request->input('sortOrder', 'asc')) === 'desc'
? 'desc'
: 'asc';
// ここで許可リストの判定を行う(後述)
match ($sortBy) {
'full_name' => $query->orderByRaw(
"concat(last_name, first_name) {$sortOrder}"
),
default => $query->orderBy($sortBy, $sortOrder),
};
orderByRaw() へ渡しているのはSQLの文字列なので、{$sortOrder} にリクエストの値がそのまま入る形にはしていません。値を取り出す時点で desc かどうかだけを見て、それ以外はすべて asc へ寄せています。sortBy の側は、このあとの許可リストで扱いを決めます。
これによって、DataGrid上では一つの「氏名」列として扱いながら、サーバー側では last_name と first_name を使って並び替えられます。
Laravel側でソート対象を制限する
Laravel側では、クライアントから送られてきた sortBy をそのまま orderBy() に渡していません。ソート可能な項目を許可リストで管理しています。
$sortableColumns = [
'id',
'full_name',
'email',
'birthday',
];
許可されていない値の場合はソート処理を行いません。
if (
! $sortBy ||
! in_array($sortBy, $sortableColumns, true)
) {
return;
}
フロント側には SORT_FIELD_MAP がありますが、Laravel側でも実際にソートへ使用できる項目を許可リストで決めています。これにより、クライアントから渡された任意の列名をそのまま orderBy() へ使用しない構造になっています。
URLへの保存と復元
ページ番号やソート条件をReactのstateだけに持っていると、ブラウザをリロードしたときに初期状態へ戻ります。今回はリロード後にも一覧状態を復元したかったため、React Routerの useSearchParams() を使ってURLへ状態を残しました。
たとえば次のようなURLです。
/users?page=1&pageSize=20&sortBy=email&sortOrder=asc
ここで気をつけたいのは、このURLの page がDataGridの paginationModel.page をそのまま保存した値だという点です。0始まりなので、page=1 は画面上の2ページ目を指します。 APIへ送るときは前述のとおり page + 1 するため、同じ状態でも次のように値がずれます。
画面のURL /users?page=1
APIリクエスト GET /api/users?page=2
page という同じ名前ですが、URLに残しているのはDataGrid側の状態、APIへ送るのはLaravelの起点に合わせた値です。
同じことは sortBy にも当てはまります。URLへ保存しているのは sortModel の field なので、DataGrid側の名前です。氏名で並び替えた場合、URLは sortBy=fullName、APIリクエストは sortBy=full_name になります。
URLから初期状態を復元する
一覧画面の初期化時に、URLからページング・ソート状態を読み込みます。
const [searchParams, setSearchParams] = useSearchParams();
const [paginationModel, setPaginationModel] = useState({
page: Number(searchParams.get('page') ?? 0),
pageSize: Number(searchParams.get('pageSize') ?? 10),
});
const initialSortField = searchParams.get('sortBy');
const initialSortOrder = searchParams.get('sortOrder');
const [sortModel, setSortModel] = useState<GridSortModel>(
initialSortField && initialSortOrder
? [{ field: initialSortField, sort: initialSortOrder as 'asc' | 'desc' }]
: [],
);
stateの変更をURLへ反映する
stateからURLへの反映は useEffect で行っています。
useEffect(() => {
updateSearchParams(setSearchParams, {
page: paginationModel.page,
pageSize: paginationModel.pageSize,
});
}, [paginationModel, setSearchParams]);
useEffect(() => {
const sort = sortModel[0];
updateSearchParams(setSearchParams, {
sortBy: sort?.field,
sortOrder: sort?.sort,
});
}, [sortModel, setSearchParams]);
どちらも paginationModel / sortModel を依存配列へ入れているので、DataGridの操作で値が変わったときに実行されます。あわせて初回マウント時にも一度実行されるため、page や pageSize を持たないURLで開いた場合は、その時点で初期値がURLへ書き込まれます。
URLとstateを常時双方向に同期しているわけではありません。URLからstateを読むのは初期化のときだけで、そのあとはstateからURLへの一方向です。
初期表示
URL
↓ 初期化(useState)
paginationModel / sortModel
↓ 初回マウントのuseEffect
URL
ユーザー操作後
paginationModel / sortModel
↓ useEffect
URL
query parameterを部分更新する
URL同期を最初に実装したときは setSearchParams() を直接使用していました。その後、updateSearchParams(setSearchParams, updates) という関数へ共通化しました。URLSearchParams の既存の内容を引き継ぎつつ、渡された updates のキーだけを更新し、値が undefined / null / 空文字ならそのキーを削除します。ページング状態を更新するときに、既存の sortBy / sortOrder を消さずに済みます。
なお、現在の実装でURLへ保存しているのは page / pageSize / sortBy / sortOrder の4つです。氏名やメールアドレスなどの検索条件はURLへ保存していないため、この記事でもページング・ソート状態に範囲を絞ります。
背景:なぜサーバー側で処理するのか
全ユーザーを取得してクライアント側でページ分割・並び替えする構成では、ページ単位で取得した場合に表示中のデータだけを並び替えても全体としては正しい順序にならず、全件取得する場合はデータ量が増えるほど通信量・処理量も増えます。そこで今回は、ページングとソートの両方をLaravel側で処理し、現在の表示に必要なデータだけを返す構成にしました。
大量データを扱う場合を想定した設計であり、実際に大量データによる性能問題が発生してから変更したわけではありません。
まとめ
MUI DataGridのserver-side pagination / sortingをLaravel APIと組み合わせる場合、DataGridをserver modeにするだけでは完結しません。冒頭のデータフローで示した各境界を、実際に接続する必要があります。
接続時に確認すること:
- DataGridのページ番号を、APIへ渡す前に変換しているか
- URLへ保存しているのはどちら側の値か(DataGrid側か、API側か)
- DataGridの
fieldとAPIのフィールド名が一致しない列はないか - 表示用に組み立てている列を、サーバー側でどう並び替えるか決めているか
- 並び順の値をSQLへ渡す前に、想定した値だけに絞っているか
- サーバー側でもソート対象の列を制限しているか
- URLとstateのどちらからどちらへ、いつ反映されるかを把握しているか
題材にしたリポジトリは公開しているので、記事中のコードは実際の実装で確認できます。
React / TypeScript / Laravel を中心としたポートフォリオや、これまでの経験・スキルについては以下にまとめています。
境界ごとの変換を明示するという観点で、実装経緯を含めて整理したZenn版も公開しています。