0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MUI DataGridのserver pagination・sortingをReact Query+Laravelで実装する

0
Last updated at Posted at 2026-08-18

はじめに

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版も公開しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?