Advanced

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.

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.

0 requests made 0 loaded, 0 pending
Order

No data

0 rows
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.

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.

POST /datagrid/api/people

No data

0 rows

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.

FieldWhat it carries
startRow / endRowThe range being asked for. startRow is inclusive, endRow is not
sortModelSortState[], in priority order. sortField travels rather than the column id where one is set
filterModelFilterRequest: quick, quickFields and the per-column conditions
advancedFilterThe nested condition tree, when advancedFilter() is registered
groupBy / groupKeysWhich columns are grouped, and which group is being opened, outermost first
pivotThe 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.

Order

No data

0 rows
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. -->

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.

no error idle

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 read

Getting 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.

Nothing is added to the grid: you get the array.
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

FeatureWhat happens
Conditional formattingA 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 replaceSearches the loaded page. partial says so, and replacing is refused unless serverReplace is set
exportCsvWrites the loaded rows, and the toolbar names the item "Loaded rows" rather than promising the set
sortFn and filter predicatesFunctions 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.

OptionDescription
modeAsk for the page the reader is on, or for whichever blocks the viewport touches
blockSizeRows per request. A network decision rather than a rendering one
maxBlocksHow many blocks are kept before the oldest is dropped
placeholderThe 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
onErrorA failed block does not empty the grid. The error lands on grid.status with a retry
groupBy / groupKeysOf / isChildOf / getRowMetaGrouping the server does, opened one level at a time