Advanced

Server Row Model

rowModel: 'server' tells the pipeline that data already holds exactly what should be shown. The features stay registered, because their state, their chrome and their events are what the fetch listens to.

Every grid on this page is real: each one talks to an endpoint in this site that filters, sorts and slices 500 rows the way a database would, with a deliberate 300ms delay so the loading state is visible.

A Real Round Trip

The grid holds one page at a time. Sorting, filtering and paging each send a request, and the body of the last one is printed underneath.

loading 0 requests 0 rows on the server 0 rows in the client
Country
0 rows

Last request body:

none yet
import {
  createDataGrid, filtering, pagination, sorting,
  getSorting, toFilterRequest, toSortRequest, SELECTION_COLUMN_ID
} from '@sv5ui/datagrid';

// rowModel: 'server' tells the pipeline that data already holds exactly
// what should be shown. Filtering, sorting and windowing pass their rows
// through untouched, while the features stay registered, because their
// state, their UI and their events are what a server model listens to.
const grid = createDataGrid<Person>({
  columns,
  data: [],
  getRowId,
  rowModel: 'server',
  features: [sorting(), filtering(), pagination({ pageSize: 10 })]
});

// The events are the trigger, and the mount is the first load.
for (const event of ['sortChanged', 'filterChanged', 'pageChanged'] as const) {
  grid.events.on(event, () => void load());
}
onMount(() => void load(true));

// Latest wins: typing into the quick filter sends a request per keystroke
// and they do not come back in the order they left. lastSent is a separate
// guard - setRowCount can clamp the page and emit pageChanged, which would
// otherwise fetch a page nothing asked for.
let ticket = 0;
let lastSent = '';

async function load(force = false) {
  const paging = getPagination(grid);
  const sorting = getSorting(grid);

  // The columns a bare query applies to: whatever is visible when it is
  // typed, which is what the client would have matched. The checkbox
  // column is visible too and holds nothing, so it is not a field.
  const quickFields = grid.columns.visible
    .filter((column) => column.id !== SELECTION_COLUMN_ID)
    .map((column) => column.id);

  const body = JSON.stringify({
    filter: toFilterRequest(getFiltering(grid)?.model ?? { quick: '', columns: {} }, quickFields),
    sort: toSortRequest(sorting?.sort ?? [], grid.columns.defs, sorting?.nulls),
    page: paging?.page ?? 1,
    pageSize: paging?.pageSize ?? 10
  });
  if (!force && body === lastSent) return;

  const mine = ++ticket;
  lastSent = body;
  const response = await fetch('/api/rows', { method: 'POST', body });
  const { rows, total } = await response.json();
  if (mine !== ticket) return;     // an older answer, already out of date

  grid.data = rows;                // one page
  grid.api.setRowCount?.(total);   // what the footer counts against
}

What Triggers a Load

The events, and nothing else. Drive this grid with the buttons and watch its counter: one interaction, one request, and no request at all while it sits idle.

Reloading from an $effect that reads the grid is the trap this page was written into first: the effect reads the page and the row count, the response writes both back, and the loop never settles.

Three events are worth listening to, and a fourth is worth knowing about. sortChanged, filterChanged and pageChanged are the triggers. rowCountChanged is the answer coming back: setRowCount emits it when the server's total differs from the one before, and the announcer reads that number rather than counting the page it holds. Do not reload from it, or you have rebuilt the loop.

0 requests
0 rows
// Do NOT reload from an $effect that reads the grid. This looks
// innocent and never settles:
$effect(() => {
  void load();   // reads page, pageSize, the filter model and the sort
});

// load() writes the answer back:
grid.data = rows;
grid.api.setRowCount?.(total);

// setRowCount writes the row count that page and pageCount are derived
// from, which the effect read on its way in, so the effect re-runs and
// fetches again. The 300ms server delay is the only thing pacing it.

// The grid already tells you when to reload. Listen, and load once on
// mount:
for (const event of ['sortChanged', 'filterChanged', 'pageChanged'] as const) {
  grid.events.on(event, () => void load());
}
onMount(() => void load(true));

The Request Shapes

The two builders produce wire shapes kept deliberately separate from the internal models, so they can stay frozen while those grow. The panels below are rebuilt from this grid's state as you change it, so they move before any request goes out.

0 rows

toFilterRequest

{
  "quick": "",
  "quickFields": [
    "name",
    "team",
    "salary"
  ],
  "columns": {}
}

toSortRequest

[]

A column's sortField is what travels, so a column id that is a UI concern never has to be one your database recognises.

// The two request builders produce normalized wire shapes, kept
// deliberately separate from the internal models so they can stay frozen
// while those grow. They grow only by carrying something a backend could
// not otherwise know, which is what quickFields and nulls are.

toFilterRequest(grid.api.getFilterModel(), quickFields);
// {
//   quick: 'ada',
//   quickFields: ['name', 'team', 'salary'],
//   columns: {
//     salary: { join: 'and', conditions: [{ kind: 'number', op: 'gte', value: 90000 }] }
//   }
// }
// Always a list and a join, even where the model kept one condition flat.
// quickFields names the columns the query applies to: the visible ones, in
// the order they are shown. Leave it out and a backend is guessing.

toSortRequest(getSorting(grid).sort, grid.columns.defs, getSorting(grid).nulls);
// [{ field: 'lastName', direction: 'asc', nulls: 'first' }]
// A column's sortField is what travels, so an id that is a UI concern need
// not be one your database recognises.
//
// nulls is written as the side the blanks actually land on, which is not
// always the side the option names: on the client a blank sorts as the
// smallest value, so nulls: 'first' arrives as 'last' once the direction
// is descending. SQL's NULLS FIRST means first either way, so passing it
// straight through is what keeps the two orderings identical.
//
// Header groups are handled inside: the builder flattens what it is given,
// so the same call works with or without them.

What Your Backend Has To Agree With

The request carries everything the grid decided, and your backend decides the rest. Under a server row model the grid does not filter or sort what it is handed, so where the two disagree, what the reader sees is yours. These are the defaults that differ.

The grid meansA database's default
Text compares case-insensitively unless the condition sets caseSensitiveLIKE is case-sensitive in Postgres
Text orders naturally, so Item 2 comes before Item 10Item 10 comes before Item 2
Blank is null, undefined or ''IS NULL misses ''
between includes both endsvaries
A date condition means a calendar day, in the reader's own zonea timestamp in the database's zone
A percent column holds the ratio, so 5% travels as 0.05whatever the column stores

Two things the request cannot carry, because they are functions: a column's sortFn and a filter's custom predicate run on the client only. Under a server row model they are never called, and the request describes the built-in meaning of the condition instead.

The endpoint behind every grid on this page is written against that table: it folds case, orders naturally with Intl.Collator, treats '' as blank, and reads nulls off each sort entry rather than deciding for itself. That is the whole reason a grid can move from the client model to this one without reordering itself.

-- Everything the grid decided, translated. Where the two disagree,
-- what the reader sees is whatever your backend does.

-- nulls rides on every sort entry, so ORDER BY reproduces the client
-- ordering without further thought:
ORDER BY last_name ASC NULLS FIRST, salary DESC NULLS LAST

-- Text is case-insensitive unless the condition sets caseSensitive, which
-- LIKE is not in Postgres:
WHERE name ILIKE '%' || $1 || '%'

-- Blank means null or the empty string, and IS NULL misses one of them:
WHERE country IS NULL OR country = ''

-- between includes both ends:
WHERE salary BETWEEN $1 AND $2

-- A date condition means a calendar day in the reader's own zone, not an
-- instant in the database's:
WHERE joined_at >= $1::date AND joined_at < $1::date + 1

-- A percent column holds the ratio, so 5% arrives as 0.05.

Loading and Errors

The grid renders the surfaces and the fetch owns the flags. Break the endpoint below and this grid shows its error state with a working Retry; turn the switch off and press Retry to recover.

loading
0 rows
<script lang="ts">
  let loading = $state(true);
  let error = $state<string | null>(null);

  async function load() {
    loading = true;
    error = null;
    try {
      const { rows, total } = await fetchPage();
      grid.data = rows;
      grid.api.setRowCount?.(total);
    } catch (cause) {
      error = 'Could not reach the server';
    } finally {
      loading = false;
    }
  }
</script>

<!-- The grid renders the surfaces; the fetch owns the flags -->
<DataGrid {grid} toolbar {loading} {error} onRetry={load} />

Selection Stops at the Page

The grid holds one page, so select-all reaches that page rather than the result set. Tick the header checkbox, or use the button, and compare the three numbers.

What it does not do is forget the pages behind it. Select-all adds the rows in view to the selection, so ticking the header on page 1 and again on page 2 leaves ten rows chosen, and unticking page 2 hands back its five and keeps the first five. The count is yours to carry; the grid only ever knows the ids of the page it holds.

0 selected 0 rows in the client 0 rows on the server
0 rows
// What the server model does and does not cover.

// Covered: filter, sort and paging travel to the server, and setRowCount
// keeps the footer honest about a total the client never sees.

// Not covered:
//  - selection still addresses the rows in hand, so select-all reaches the
//    page rather than the result set. The ids it holds survive the page
//    turning, so a count spanning pages is real, but the rows behind the
//    ids are not, and "select every match" is yours to model.
//  - virtualization windows the page it was given: rowModel 'server' with
//    virtualization is a fixed-size page in a scroller, not infinite scroll.
//  - the quick filter travels as one string plus the fields it applies to;
//    what it means against them is the server's decision. The client also
//    matches the text a cell draws, which a backend has no renderers for.

// Debounce at the edge rather than in the grid: the quick filter already
// debounces its input, but a slider or a date range wants its own.

Exporting a Set the Grid Does Not Hold

exportCsv writes the rows the grid is holding. Under a client row model that is every row the filter left, which is what the toolbar's "All rows" means. Here the grid holds one page, so the same item is named Loaded rows instead of promising the rest. Open the export menu below and read it.

For the whole set, export on the server: onExportAll takes the item over, the file is built from the same request the grid sends, and the item goes back to being "All rows" because it can now honestly promise them. The second grid does exactly that against this site's own CSV endpoint, so filter or sort it first and the file follows.

Without onExportAll: the menu says Loaded rows

0 rows

With onExportAll: the menu says All rows, and the file is the whole result set

0 rows
<!-- exportCsv writes the rows the grid is holding. Under a server row
     model that is one page, so the item is named "Loaded rows" rather
     than promising the rest. Point it at an endpoint that streams a
     file and it goes back to being "All rows". -->
<DataGrid {grid} toolbar onExportAll={exportEverything} />

<script lang="ts">
  function exportEverything() {
    // The same request the grid sends, so the file matches the screen.
    const query = new URLSearchParams({
      filter: JSON.stringify(toFilterRequest(getFiltering(grid)!.model, quickFields)),
      sort: JSON.stringify(toSortRequest(getSorting(grid)!.sort, grid.columns.defs))
    });
    location.href = `/api/people.csv?${query}`;
  }
</script>

<!-- Selection is unaffected either way: the ids are held across pages, so
     "Selected rows" writes the ones the grid still has in hand. -->

A browser cannot be handed ten million rows to turn into a file: the string alone outgrows the tab long before the download starts, and the rows would have to be fetched page by page first. Selection is the exception, and it needs no endpoint: the ids are held across pages, so Selected rows writes the ones the grid still has in hand.

The Server Surface

MemberDescription
rowModel: 'server'Makes the filter, sort and window stages pass their rows through untouched
toFilterRequest(model, quickFields?)Normalizes the filter model for the wire: always a list and a join. quickFields names the columns a bare query applies to, which the model does not carry
toSortRequest(sort, defs, nulls?)Sends each column's sortField rather than its id, and where the blanks land on every entry
onExportAllTakes over the toolbar's "all rows" item, for the set the grid does not hold. Without it the item exports the loaded page and is named for that
setRowCount(total)The total the footer counts against, since the client only holds a page