Server Row Model
Rows fetched from a data source as the viewport moves, in pages or in blocks, with placeholders
for what has not arrived and a fetchAll for
exporting the whole set.
rowModel: 'server' with your own fetch loop: you listen for the events and assign grid.data.
This page is the other one: you hand the grid a source and it decides what to ask for and
when. Reach for Server Mode when you own the loop, and for serverRowModel() when you want blocks, placeholders and retries without writing them. A Source Is One Method
The grid asks for a range and you answer with rows and, when you know it, how many there are. The grid below is backed by 5,000 rows behind a 260ms delay, fetched 100 at a time. Scroll it, sort a column, or type in the quick filter, and watch the request count move.
No data
import {
createDataGrid, filtering, serverRowModel, sorting, virtualization
} from '@sv5ui/datagrid';
import type {
DataSource, GetRowsRequest, GetRowsResult
} from '@sv5ui/datagrid';
const source: DataSource<Order> = {
async getRows(request: GetRowsRequest): Promise<GetRowsResult<Order>> {
const response = await fetch('/api/orders?' + new URLSearchParams({
start: String(request.startRow),
end: String(request.endRow),
sort: JSON.stringify(request.sortModel),
filter: JSON.stringify(request.filterModel)
}));
const { rows, total } = await response.json();
return { rows, rowCount: total };
}
};
const grid = createDataGrid<Order>({
columns,
data: [],
getRowId: (order) => order.id,
rowModel: 'server',
features: [
sorting(),
filtering(),
virtualization({ rowHeight: 40 }),
serverRowModel(source, {
mode: 'infinite',
blockSize: 100,
maxBlocks: 8,
placeholder: (index) => ({
id: `pending-${index}`,
sku: '',
customer: '',
units: 0,
total: 0
})
})
]
});
// rowModel: 'server' is required. Without it the pipeline would filter and
// sort the page again, over rows the source already filtered and sorted.createDataGrid, not on the feature. Leave it out and serverRowModel() throws while the grid is being built, saying why: the pipeline would filter and sort the
page again on the client, over rows the source already filtered and sorted. The Same Thing, Over HTTP
The grid above answers from memory behind a timer, which keeps the moving parts visible. This
one is the shape you will actually write: the same DataSource,
but getRows POSTs to a real endpoint in this site and returns what comes back. Every row below crossed the
network.
Scroll it, sort a column, or type in the quick filter, and the log underneath fills with the calls it made, the range each one asked for and how long it took. The sorting and the filtering happen on the server: the grid sends the request and draws the answer.
No data
The last few requests
- Nothing yet. Scroll the grid.
import { toSortRequest, type DataSource, type GetRowsRequest } from '@sv5ui/datagrid';
// The client half. One POST per block, and nothing else.
const source: DataSource<Person> = {
async getRows(request: GetRowsRequest) {
const response = await fetch('/api/people', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
startRow: request.startRow,
endRow: request.endRow,
// toSortRequest turns the grid's sortModel into what a backend is
// written against: the field name, and which end blanks belong at.
sort: toSortRequest(request.sortModel, columns),
filter: request.filterModel
})
});
if (!response.ok) throw new Error(`The people service answered ${response.status}`);
const body = await response.json();
return { rows: body.rows, rowCount: body.rowCount };
}
};
const grid = createDataGrid<Person>({
columns, data: [], getRowId,
rowModel: 'server',
features: [
sorting(), filtering(),
virtualization({ rowHeight: 40, overscan: 6 }),
serverRowModel(source, {
mode: 'infinite',
blockSize: 100,
// Drawn while a block is on its way. Infinite mode will not build
// without it, because getRowId has to name a row that has not arrived.
// A negative id cannot collide with a real one.
placeholder: (index) => ({ ...blankPerson, id: -1 - index })
})
]
});
// And the server half, a SvelteKit endpoint. rowCount is the size of the
// whole result rather than of the slice: it is what lets the grid size its
// scrollbar for 5,000 rows while holding a hundred.
export const POST: RequestHandler = async ({ request }) => {
const { startRow = 0, endRow = 100, sort, filter } = await request.json();
const matched = applyQuery(rows, filter, sort);
return json({ rows: matched.slice(startRow, endRow), rowCount: matched.length });
};GetRowsRequest
What the grid tells the source. Everything it decided, and nothing it did not.
| Field | What it carries |
|---|---|
startRow / endRow | The range being asked for. startRow is inclusive, endRow is not |
sortModel | SortState[], in priority order. sortField travels rather than the column id where one is set |
filterModel | FilterRequest: quick, quickFields and the per-column conditions |
advancedFilter | The nested condition tree, when advancedFilter() is registered |
groupBy / groupKeys | Which columns are grouped, and which group is being opened, outermost first |
pivot | The crossing, when pivot() is registered: its row fields, column fields and measures, and any sort by a crossing |
interface GetRowsRequest {
startRow: number;
endRow: number;
sortModel: SortState[];
filterModel: FilterRequest;
groupKeys: unknown[];
groupBy: string[];
advancedFilter?: FilterGroup;
}
interface GetRowsResult<TRow> {
rows: TRow[];
rowCount?: number;
}
async getRows(request) {
const { quick, quickFields } = request.filterModel;
const [sort] = request.sortModel;
let rows = TABLE;
if (quick) rows = rows.filter((row) => matches(row, quick, quickFields));
if (sort) rows = order(rows, sort.columnId, sort.direction);
return { rows: rows.slice(request.startRow, request.endRow), rowCount: rows.length };
}
// Leave rowCount out and the grid keeps asking; return it once and the
// scrollbar settles. A block shorter than blockSize is also read as the end.Paged or Infinite
paged asks
for exactly the page the reader is on, and pagination() drives it. The grid above is infinite; this one is paged.
Register one or the other, never both: pagination() and virtualization() both decide which rows are in the window, so the grid refuses a pair of them while it is being
built.
No data
serverRowModel(source, { mode: 'paged' }); // the default
const paged = createDataGrid<Order>({
columns, data: [], getRowId, rowModel: 'server',
features: [
sorting(),
pagination({ pageSize: 8 }),
serverRowModel(source, { mode: 'paged' })
]
});
const infinite = createDataGrid<Order>({
columns, data: [], getRowId, rowModel: 'server',
features: [
sorting(),
virtualization({ rowHeight: 40 }),
serverRowModel(source, {
mode: 'infinite',
blockSize: 100,
maxBlocks: 8,
placeholder: (index) => ({ id: `pending-${index}` })
})
]
});
// pagination() and virtualization() both decide which rows are in the
// window, so the grid takes one or the other and throws on a pair.Placeholders, and Telling Them Apart
A row the grid has asked for but not received is drawn from placeholder(), and isLoadingRow tells it from a real one. Scroll the first grid quickly and the counter above shows pending
rows before they fill in.
<script lang="ts">
import { isLoadingRow, serverRowModel } from '@sv5ui/datagrid';
const feature = serverRowModel(source, {
mode: 'infinite',
placeholder: (index) => ({ id: `pending-${index}`, sku: '', total: 0 })
});
</script>
{#snippet skuCell({ node, value })}
{#if isLoadingRow(node.row)}
<span class="animate-pulse text-on-surface/30">loading</span>
{:else}
{value}
{/if}
{/snippet}
<!-- placeholder is required under infinite: getRowId has to return a unique
id for a row that has not arrived. -->status reads loading: true until the first block comes back, so an empty grid is never mistaken for an empty set. When a Block Fails
The error lands on grid.status with an onRetry beside it, so the body can offer a way back rather than a blank screen. Blocks that already
arrived are left where they are: a failure loses the block it was fetching, not the grid.
The button below fails the next request and then calls refresh(),
which throws every block away before asking again. So this particular sequence does empty the
grid, and that is what a first request failing looks like: no rows, an error, and a retry.
Press Retry and it fills back in.
This drives the first grid on the page, so scroll back up to watch it recover.
serverRowModel(source, {
placeholder,
onError: (error, request) =>
report(error, { startRow: request.startRow, endRow: request.endRow })
});
const model = getServerRowModel(grid)!;
model.error; // the message, or null
model.loading;
model.answered; // false until the first block comes back
model.rowCount; // the total, once a source has reported one
model.refresh(); // throw away every block and ask again
grid.status; // { loading, error, onRetry } for the body to readGetting the Whole Set
exportCsv writes the rows the grid holds, which here is whatever blocks are loaded. For everything, ask
for it: fetchAll walks the source in batches rather than asking for five thousand rows in one request.
const model = getServerRowModel(grid)!;
const everything = await model.fetchAll({
// 5000 by default, and deliberately independent of the page size the
// reader is browsing at: a 25-row page over 50,000 rows would otherwise
// mean two thousand round trips.
batchSize: 500,
onProgress: (loaded, total) => {
done = loaded;
expected = total;
}
});
everything.length; // 5000
await grid.api.fetchAllServerRows?.({ batchSize: 500 });
// It walks the source in batches. Nothing is added to the grid: you get the
// array, and what you do with it is yours.Grouping the Server Does
Grouping over rows the grid cannot see means the server groups, and the grid asks for one
group's children as it is opened. groupKeys says which group, outermost first.
serverRowModel(source, {
placeholder,
groupBy: ['country', 'city'],
groupKeysOf: (node) => [node.row.country, node.row.city],
isChildOf: (node) => (row) => row.country === node.row.country,
// RowMeta is level / expandable / fullWidth / setSize / posInSet. The
// chevron is expandable; the indent is level.
getRowMeta: (row) =>
row.isGroup ? { level: 0, expandable: true } : { level: 1 }
});
async getRows(request) {
const [country, city] = request.groupKeys;
if (country === undefined) return { rows: await countries() };
if (city === undefined) return { rows: await cities(country) };
return { rows: await orders(country, city) };
}A Pivot the Server Answers
Register pivot() beside a server row model and the crossing rides on every request as pivot. The server measures it and sends back gathered rows with pivotColumns, the keys of the column axis, which the client cannot learn from the part of the data it holds. The contract is on Server Pivot.
A change to the crossing fetches on its own, as a sort or a filter does.
What It Cannot Do
The grid holds one page, so anything needing the whole set either changes meaning or is put aside. None of this is hidden: each one says so at the time.
Under a Server Row Model
| Feature | What happens |
|---|---|
Conditional formatting | A colour scale, data bar, duplicates and top N need the whole column, so they are skipped. skippedRules lists them; an expression rule still works |
Find and replace | Searches the loaded page. partial says so, and replacing is refused unless serverReplace is set |
exportCsv | Writes the loaded rows, and the toolbar names the item "Loaded rows" rather than promising the set |
sortFn and filter predicates | Functions cannot travel, so the request carries the built-in meaning of the condition instead |
const format = getConditionalFormatting(grid)!;
format.skippedRules; // ['colorScale:0', 'dataBar:1'] under a server row model
const find = getFindReplace(grid)!;
find.partial; // true: the count is of the loaded page
// Colour scales, data bars, duplicates and top N need the whole column, so
// they are put aside. An expression rule reads one row and still works.
// Replacing is refused unless serverReplace is set, exportCsv writes the
// loaded rows, and a column's sortFn or filter predicate never travels.Options
serverRowModel(source, options). The source is the first argument, not an option.
| Option | Description |
|---|---|
mode | Ask for the page the reader is on, or for whichever blocks the viewport touches |
blockSize | Rows per request. A network decision rather than a rendering one |
maxBlocks | How many blocks are kept before the oldest is dropped |
placeholder | The row drawn while a real one is on its way. isLoadingRow finds it. Infinite mode refuses to build without one, because getRowId has to return a unique id for a row that has not arrived |
onError | A failed block does not empty the grid. The error lands on grid.status with a retry |
groupBy / groupKeysOf / isChildOf / getRowMeta | Grouping the server does, opened one level at a time |