08 — ServerDataGrid decoded
Read this first: this chapter reads the wrapper every table page in MotorPH renders — the
component that turns AG Grid's infinite row model into a handful of props a page can actually
supply. You will follow one scroll event from the grid's block cache to a Spring Pageable, and
discover which parts of a grid survive a browser reload and why the rest deliberately do not.
Time: about 35 minutes. Assumes lesson 07.
The grid asks; the page answers
AG Grid's infinite row model never holds the whole table. It holds blocks of rows, and when you
scroll into rows it does not have, it asks a registered datasource for them. ServerDataGrid wires
that up once:
rowModelType="infinite"
datasource={datasource}
cacheBlockSize={cacheBlockSize ?? pageSize}
maxBlocksInCache={maxBlocksInCache ?? 10}
A page never sees any of this. It supplies four things — column defs, a sort whitelist, and two functions:
readonly sortableFields?: Set<string>;
readonly buildParams: (
filterModel: Record<string, unknown>,
sortModel: SortModelItem[],
page: number,
size: number
) => TApiParams;
readonly fetchPage: (params: TApiParams) => Promise<PageResult<TRow>>;
buildParams translates grid state into the page's typed API params; fetchPage calls an API
wrapper from lesson 04. Everything between a scroll and those calls is
this file's job.
One translation: rows become pages
Predict: the grid's block size is 20, and you scroll to where rows 40–59 should be. AG Grid
calls the datasource with startRow: 40, endRow: 60. What page and size reach the server?
Write your answer down before reading on.
getRows: params => {
const size = params.endRow - params.startRow;
const page = Math.floor(params.startRow / size);
const sortModel = params.sortModel.filter(m =>
sortableFieldsRef.current.has(m.colId)
);
const apiParams = buildParamsRef.current(
params.filterModel as Record<string, unknown>,
sortModel,
page,
size
);
onLoadingChangeRef.current?.(true);
fetchPageRef
.current(apiParams)
.then(res => {
onLoadingChangeRef.current?.(false);
params.successCallback(res.content, res.totalElements);
/* … toggle the no-rows overlay … */
})
.catch((err: unknown) => {
/* … onError or a toast, then … */
params.failCallback();
});
},
size = 60 − 40 = 20, page = ⌊40 ÷ 20⌋ = 2. AG Grid thinks in row indexes; Spring thinks in
page numbers. This subtraction-and-division is the entire bridge — and it only holds while block
size equals page size, a constraint enforced twice more below.
The sortModel.filter line is lesson 07's column contract, enforced at
the last possible moment. A column can render a field the server never agreed to ORDER BY — a
client-side computed value, an unindexed join. Clicking its header still flips the arrow, but the
sort never leaves the browser, instead of arriving at the backend as a 400.
successCallback(res.content, res.totalElements) hands over the rows and the total. The total is
what lets the grid draw a scrollbar for rows it has never fetched, and lets the pagination bar say
"of 1,873 records". failCallback marks the block failed so the grid can retry it later instead of
showing a spinner forever.
Why nothing here ever changes identity
The datasource is memoized with an empty dependency array — if its identity changed, AG Grid would
treat it as a brand-new data source and refetch everything. But pages pass buildParams and
fetchPage as fresh arrow functions on every render. The file's answer:
// Keep latest prop values accessible inside the stable datasource closure without recreating it.
const buildParamsRef = useRef(buildParams);
const fetchPageRef = useRef(fetchPage);
const sortableFieldsRef = useRef(sortableFields);
The closure stays frozen; the refs inside it always point at the latest props. The same trick guards the row-action handler, and the comment on it records the incident that justifies all this ref plumbing:
// Stable identity that always calls the latest onRowAction. Callers routinely pass a fresh
// handler each render (a plain function, not useCallback'd); without this indirection that
// fresh identity would flow into augmentedColumnDefs, so any unrelated parent re-render
// (e.g. a saved-views query resolving) would rebuild every columnDef and force AG Grid to
// tear down and recreate its columns — destroying an open filter popup's in-progress input
// mid-type.
An unrelated query resolving somewhere above the grid destroyed the filter popup a user was typing into. That is why identity stability is a design rule here, not a micro-optimization. This is only a taste — lesson 09 owns the full ref-stability story on the page side.
What survives a reload
Every grid names itself with a gridStateKey, and two pieces of state are written under that key —
by default to localStorage:
const saveGridState = (api: GridApi<TRow>) => {
if (!api.isDestroyed() && persistenceRef.current) {
persistenceRef.current.save(gridStateKey, {
columns: api.getColumnState(),
filter: api.getFilterModel(),
});
}
};
Saves fire on exactly the events that change those two things:
onColumnMoved={e => { saveGridState(e.api); }}
onColumnResized={e => { if (e.finished) saveGridState(e.api); }}
onColumnVisible={e => { saveGridState(e.api); }}
onSortChanged={e => { saveGridState(e.api); }}
e.finished matters on resize: the event streams continuously during a drag, so the grid writes
once, on release. Sort lives inside column state — that is why onSortChanged saves the same blob.
Restore happens once, in onGridReady:
const saved = persistenceRef.current?.load(gridStateKey);
if (saved && typeof saved === 'object') {
try {
/* … */
event.api.applyColumnState({ state: p.columns, applyOrder: true });
event.api.setFilterModel(p.filter);
} catch {
persistenceRef.current?.remove(gridStateKey);
}
} else if (initialState) {
Two decisions are visible in that shape. The catch deletes corrupt saved state — one bad write
must not brick a grid on every future visit. And initialState (a page's default sort or filter)
applies only when nothing is persisted, so a page's defaults never fight a layout the user
already chose.
So: column order, widths, visibility, sort, and filters survive a reload. The current page, scroll position, selection, and page size do not — they reset. Layout is a preference; position is a moment.
Stale caches, and the two knobs that must move together
Change a filter and every cached block is now wrong — each was fetched under the old filter. The grid's handler saves state, then throws the cache away:
onFilterChanged={e => { saveGridState(e.api); if (e.api.isDestroyed()) { return; } e.api.purgeInfiniteCache(); }}
purgeInfiniteCache discards every block, so the grid immediately re-asks getRows for the
visible one — and the new filterModel rides along in params. That is the whole
filter-to-network path: no effect, no state, just cache invalidation.
Changing the page size is the same problem plus a trap, and the comment names it:
const handlePageSizeChange = (size: number) => {
const api = internalRef.current;
if (!api || api.isDestroyed()) return;
// cacheBlockSize is independent of paginationPageSize at runtime (AG Grid infinite row
// model) — both must move together, then the block cache purged, or page/block-boundary
// math desyncs (see getRows' size = endRow - startRow above).
api.setGridOption('cacheBlockSize', size);
api.setGridOption('paginationPageSize', size);
api.purgeInfiniteCache();
api.paginationGoToFirstPage();
};
AG Grid will happily run with a 20-row block cache under a 50-row pagination page. Then a block
spans page boundaries, endRow − startRow no longer equals the page size, and the Math.floor
in getRows computes page numbers the server never meant. Both knobs, together, then purge.
The column every page gets for free
Pass rowActions and the grid appends one more column to whatever the page declared:
{
colId: 'actions',
headerName: '',
width: ACTIONS_COL_WIDTH,
pinned: 'right',
/* … sortable/filter/resizable off, suppressMovable on … */
cellRenderer: RowActionsCell,
cellRendererParams: { rowActions, onRowAction: stableOnRowAction },
}
RowActionsCell renders the kebab menu: actions hide per-row via hidden(row), and label, icon,
and color may each be functions of the row. Its container stops onMouseDown propagation so
opening the menu does not also count as a row click — on most pages a row click opens a drawer.
Pinning only works if the grid scrolls its own centre section, which is what the comment above the container warns about:
{/* `minWidth` is only a narrow-screen floor. It must stay well under a typical content width:
anything wider makes this outer box scroll the ENTIRE grid — pinned columns included —
which slides the pinned actions column off-screen and defeats the point of pinning. … */}
The bar underneath
The grid sets suppressPaginationPanel and renders its own GridPaginationBar instead: numbered
page tokens with ellipses, first/prev/next/last, a jump-to-page input once totalPages passes
pageJumpThreshold, and — when a page opts in with pageSizeOptions — a page-size picker. That
picker is a Menu, not a NativeSelect, and its doc comment says exactly why:
/**
* Page-size picker built from the same Menu vocabulary as the grid's View▾ and Table▾ menus …
* rather than a NativeSelect. A native
* select couldn't match those neighbours: Chakra styles selects at radius `md` and buttons at
* `lg`, so the old control sat visibly off-system next to every other grid affordance.
*/
The jump input carries its own recorded decision — // Clamp rather than ignore: typing 99 into a 4-page grid means "the end", and doing nothing at all reads as a broken control. — and commits
from Enter, blur, and the Go button, which is why its empty-string guard is load-bearing.
When a result comes back empty, the default overlay says "No records found"; pages replace it with
createEmptyState(...) from EmptyState.tsx, whose comment tells you to build it "once per page
via useMemo (a fresh function identity each render is unnecessary overlay churn)".
The server's half of the deal
Predict: the enterprise grid (lesson 09) appends a
fields= query param derived from the columns you have visible. What changes in the SQL when
fields= arrives? Answer before reading on.
/**
* Returns full {@link EmployeeDto}s by default. When {@code fields} is supplied — the enterprise
* grid sends it, derived from its visible columns — the response is instead a page of flat
* maps holding only those columns, so hiding a column narrows the query rather than just the
* rendering.
* // …
*/
@GetMapping
@PreAuthorize("hasAuthority('" + PermissionConstants.HR_EMPLOYEES_VIEW + "')")
public ResponseEntity<PageResponseDto<?>> list(
Pageable pageable,
@RequestParam(required = false) String fields,
@ModelAttribute EmployeeListFilter filter) {
if (!StringUtils.hasText(fields)) {
return ResponseEntity.ok(PageResponseDto.of(employeeService.list(pageable, filter)));
}
Set<String> requested = /* … split on commas, trim, collect … */;
return ResponseEntity.ok(PageResponseDto.of(employeeService.listProjected(pageable, filter, requested)));
}
The resolution is in the Javadoc's own words: the SELECT list shrinks. Without fields, the
service loads full entities and maps them to EmployeeDto — every mapped column, every join the
DTO needs. With fields, listProjected builds a JPA Tuple query selecting only the requested
paths, and the response rows are flat maps of exactly those columns. Hiding a column in the grid
narrows the SQL, not just the rendering. The deep request-to-SQL trace belongs to
the guided tour.
Note what the frontend got for free: Pageable binds page, size, and sort straight from the
query string — the numbers getRows computed arrive without a line of custom binding — and the
filter params bind into EmployeeListFilter by component name.
Where this shows up in MotorPH
- frontend/src/ui/ag-grid/ServerDataGrid.tsx — the wrapper itself
- frontend/src/ui/ag-grid/GridPaginationBar.tsx — tokens, jump input,
PageSizeMenu - frontend/src/ui/ag-grid/EmptyState.tsx — entity-specific no-rows overlays
- frontend/src/ui/ag-grid/useGridPagination.ts and useGridAutoSize.ts — the two hooks the wrapper leans on
- backend/src/main/java/com/motorph/payroll/controller/EmployeeController.java — the list endpoint with
fields= - ../frontend/ag-grid.md — the exhaustive prop and option reference; this lesson deliberately is not that
Recap
- A scroll becomes
getRows(startRow, endRow)becomespage/size—size = endRow − startRow,page = ⌊startRow ÷ size⌋, which is the entire bridge between AG Grid's row indexes and Spring'sPageable. - Sort is whitelisted before it leaves the browser —
sortableFieldsdrops any column the server never agreed toORDER BY, enforcing lesson 07's contract at the network boundary. - The datasource and its callbacks ride refs — a changed datasource identity means a full refetch, and a changed columnDef identity once destroyed a filter popup mid-type.
- Filter and page-size changes purge the block cache — cached blocks were fetched under old params, and
cacheBlockSize+paginationPageSizemust move together because AG Grid treats them as independent at runtime. - Column state and filters persist per
gridStateKey; page, scroll, and selection do not — layout is a preference worth keeping, position is a moment worth forgetting.