Package · mui-data-grid

Wiring the table

In server mode the table stops computing anything on its own: it pushes its state outward and takes a finished page of rows. This page is about joining it to your endpoint so the contract lines up from both sides.

Turn server mode on

By default DataGrid paginates, sorts and filters whatever it already holds in memory. Three props hand each of those to the server:

tsx
<DataGrid
paginationMode="server"
sortingMode="server"
filterMode="server"
rowCount={rowCount}
{...rest}
/>

rowCount is required: without a total the pager doesn’t know how many pages to offer and concludes the data ended on the first one. That value is exactly MuiGridResponse::$rowCount.

The three modes are independent

Forgetting one of them is the classic mistake. Leave filterMode on the client and the table filters the page it already received: the user types a condition and sees nothing, even though matches exist on the server.

Send the state

The table’s state arrives through three callbacks. Collect them into one request body:

tsx
const [rows, setRows] = useState([]);
const [rowCount, setRowCount] = useState(0);
const [loading, setLoading] = useState(false);
const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 25 });
const [sortModel, setSortModel] = useState([]);
const [filterModel, setFilterModel] = useState({ items: [] });

useEffect(() => {
const controller = new AbortController();
setLoading(true);

fetch('/articles', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  signal: controller.signal,
  body: JSON.stringify({
    page: paginationModel.page,
    pageSize: paginationModel.pageSize,
    sortModel,
    filterModel,
  }),
})
  .then((r) => r.json())
  .then((data) => { setRowCount(data.rowCount); setRows(data.rows); })
  .finally(() => setLoading(false));

return () => controller.abort();
}, [paginationModel, sortModel, filterModel]);

The AbortController is not decoration: users page faster than the server answers, and without cancellation responses interleave — the table paints the previous page over the current one.

The name contract

A table column’s field and a schema field name are the same string.

tsx
const columns = [
{ field: 'title',      headerName: 'Title', flex: 1 },
{ field: 'views',      headerName: 'Views', type: 'number' },
{ field: 'authorName', headerName: 'Author' },
];
php
GridSchema::make(
  GridColumn::for('title',      'a.title')->filterable(FilterType::String)->sortable(),
  GridColumn::for('views',      'a.views')->filterable(FilterType::Number)->sortable(),
  GridColumn::for('authorName', 'au.name')->filterable(FilterType::String)->sortable(),
);

A mismatch shows up differently depending on the operation: a filter on an unknown field returns 400, while a sort on it silently does nothing — the table looks healthy but clicking the header changes nothing. The reasoning behind that asymmetry is in the Mental model.

Names must match, types need not

type: 'number' on a table column only affects which operators the UI offers. The server relies on the schema’s FilterType, so the type gate holds even for requests that bypass the UI entirely.

The row identifier

DataGrid requires a unique id field on every row. If your SELECT doesn’t return one, or names it differently, tell the table where to look:

tsx
<DataGrid rows={rows} getRowId={(row) => row.uuid} />

The simplest way to avoid this is to include the primary key in select() even when no column displays it. It travels to the browser and is never shown.

php
->select('a.id, a.title, a.views, au.name author_name')

Shape the rows

The fourth argument to wrap() is a mapper applied to the rows of the current page (not the whole table). That is the right place to turn a flat database row into whatever object the frontend finds convenient:

php
final readonly class ArticleRow
{
  public function __construct(
      public int $id,
      public string $title,
      public ?array $author,
  ) {
  }

  public static function from(object $r): self
  {
      return new self(
          id: $r->id,
          title: $r->title,
          author: $r->author_id ? ['id' => $r->author_id, 'name' => $r->author_name] : null,
      );
  }
}

return MuiGrid::wrap($repo, $request, $schema, fn ($r) => ArticleRow::from($r));

Result

json
{
"rowCount": 134,
"rows": [
  { "id": 12, "title": "Winter internals", "author": { "id": 3, "name": "Ada" } }
]
}

A nested field is displayed through valueGetter:

tsx
{ field: 'authorName', headerName: 'Author', valueGetter: (v, row) => row.author?.name }

The mapper does not change filtering

Schema field names refer to SQL, not to the mapper’s output. If the mapper renamed author_name into author.name, filtering still goes through authorName — the name declared in the schema.

Handling refusals

A rejected filter comes back as a 400 with a message. Showing it beats silently leaving an empty table:

tsx
.then(async (r) => {
if (!r.ok) throw new Error((await r.json()).message ?? 'Could not load data');
return r.json();
})
.then((data) => { setRowCount(data.rowCount); setRows(data.rows); })
.catch((e) => setError(e.message))

In practice a 400 from the grid means one of two things: the UI sent a field that is not in the schema, or an operator the column’s type does not allow. Both are configuration mismatches rather than user actions, which makes them worth logging.

Next steps