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:
<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:
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.
const columns = [
{ field: 'title', headerName: 'Title', flex: 1 },
{ field: 'views', headerName: 'Views', type: 'number' },
{ field: 'authorName', headerName: 'Author' },
];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:
<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.
->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:
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
{
"rowCount": 134,
"rows": [
{ "id": 12, "title": "Winter internals", "author": { "id": 3, "name": "Ada" } }
]
}A nested field is displayed through valueGetter:
{ 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:
.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
- Joined columns — columns from related tables.
- Filters outside the grid — a global search box and other filters that aren’t columns.
- Operators — what the UI actually sends.