Advanced

Worker Row Model

The same data source, off the main thread. A columnar copy is loaded into a worker that filters and sorts on indices, and falls back to the main thread if the worker is lost.

Fifty Thousand Rows, Sorted Elsewhere

Sort a column or type in the quick filter below. The work happens in a worker; the main thread only draws the window that comes back.

running on the main thread idle
Reading

No data

0 rows
import {
  createDataGrid, filtering, serverRowModel, sorting,
  virtualization, workerDataSource
} from '@sv5ui/datagrid';

const source = workerDataSource(readings, {
  columns,
  searchText: (value) => String(value ?? ''),
  onFallback: (reason) => console.warn('back on the main thread:', reason)
});

const grid = createDataGrid<Reading>({
  columns,
  data: [],
  getRowId: (row) => row.id,
  rowModel: 'server',
  features: [
    sorting(),
    filtering(),
    virtualization({ rowHeight: 40, overscan: 6 }),
    serverRowModel(source, {
      mode: 'infinite',
      blockSize: 200,
      placeholder: (index) => ({
        id: `pending-${index}`,
        sensor: '',
        site: '',
        celsius: 0,
        humidity: 0
      })
    })
  ]
});

Where the Rows Come From

The grid above was handed an array the page had already built, which no application has. The rows come from a server, and a worker row model is not an alternative to one: it is what to do with the rows after they arrive.

This grid made one request for the whole working set, handed it to the worker with setRows, and has not spoken to the server since. Sort it, filter it, search it: the log underneath stays at one line, because every one of those is answered off the main thread from rows that are already here.

That is the trade, and it is a real one. A server row model asks again on every sort and works at any size. This asks once and then answers instantly, for as long as the set fits in memory.

POST /datagrid/api/people 0 rows in the worker usingWorker: false

No data

0 rows

The last few requests

  • Loading the working set...
// A worker row model is not an alternative to a server. It is what to do
// with the rows once the server has handed them over: one download, then
// every sort and filter after it answered off the main thread.
const source = workerDataSource<Person>([], { columns });

const grid = createDataGrid<Person>({
  columns, data: [], getRowId,
  rowModel: 'server',
  features: [
    sorting(), filtering(),
    virtualization({ rowHeight: 40, overscan: 8 }),
    serverRowModel(source, { mode: 'infinite', blockSize: 200, placeholder })
  ]
});

// One request brings the working set down. setRows rebuilds the columnar
// store in chunks, so no single frame is spent on it.
const { rows } = await (await fetch('/api/people', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ startRow: 0, endRow: 5000 })
})).json();

source.setRows(rows);
getServerRowModel(grid)!.refresh();

// The trade is plain. A server row model asks again on every sort, and works
// at any size. This asks once, and then answers instantly for as long as the
// set fits in memory. Past a few hundred thousand rows, go back to the
// server; below that, this is one round trip instead of hundreds.

How It Works

Three Steps

Sorting indices is the point: nothing is copied per comparison, and a row crosses the boundary once.

StepWhat happens
A columnar copyThe rows are read once into one array per column, pulled through each column's accessor. That copy is what the worker holds
Chunked inThe copy crosses in chunks, yielding to paint between them, so the first render is not held up by the load
Indices, not rowsA query filters and sorts indices and sends back only the window the grid asked for, so nothing is copied per comparison
const source = workerDataSource(readings, { columns });

source.usingWorker;          // true until it falls back
source.setRows(nextRows);    // replace the data and rebuild the copy
source.dispose();            // terminate the worker

// The rows are read once into one array per column, chunked across yielding
// to paint between chunks, and every query then filters and sorts indices
// rather than rows.

Losing the Worker

If the worker errors it is terminated, the queries waiting on it are answered on the main thread instead, and onFallback says why. The grid keeps working either way, which is exactly the problem: without wiring onFallback a page that has quietly lost its worker looks like a page that never had one.

let onWorker = $state(true);

const source = workerDataSource(readings, {
  columns,
  onFallback: (reason) => {
    onWorker = false;
    report(reason);
  }
});

// inline skips the worker deliberately, for a test or an environment
// without one.
const local = workerDataSource(readings, { columns, inline: true });
local.usingWorker;   // false

What Cannot Cross

A function cannot be sent to a worker, and two column options are functions the query needs: sortFn and a filter.predicate of your own. A column carrying either has its query run on the main thread as well, so the worker stops being a saving for it.

const columns: ColumnDef<Reading>[] = [
  { id: 'site', header: 'Site', filter: 'set' },

  // Both of these are functions, so the query for this column runs on the
  // main thread as well as in the worker.
  { id: 'sensor', header: 'Sensor', sortFn: (a, b) => natural(a.sensor, b.sensor) },
  {
    id: 'celsius',
    header: 'Celsius',
    filter: { type: 'number', predicate: (value, row) => row.celsius > 0 }
  },

  // An accessor is fine: it is evaluated while the columnar copy is built.
  { id: 'fahrenheit', header: 'F', accessor: (row) => row.celsius * 1.8 + 32 }
];

Options

workerDataSource(rows, options). The rows are the first argument, not an option.

OptionDescription
columnsWhat to read into the columnar copy
nullsWhere blanks land in a sort
readValueRead a value yourself. Return undefined to leave the cell to the grid
searchTextThe text a quick filter matches. Omit it and no text index is built, which is cheaper when nothing searches
groupRowThe row a group stands for, under groupBy
onFallbackCalled when the worker is lost and the work returns to the main thread
inlineSkip the worker deliberately, for a test or an environment without one

What It Returns

A DataSource and a handle in one object.

MemberWhat it does
usingWorkerFalse once it has fallen back, or when inline was set
setRows(rows)Replace the data and rebuild the columnar copy
dispose()Terminate the worker
getRows(request)The DataSource half, which serverRowModel() calls. You never call it yourself

Everything Else

workerDataSource(readings, {
  columns,
  nulls: 'last',
  readValue: (row, column) =>
    column.id === 'total' ? row.qty * row.price : undefined,
  searchText: (value) => String(value ?? ''),
  groupRow: (keys, count) => ({ id: keys.join('/'), name: keys.at(-1), count }),
  onFallback: (reason) => report(reason),
  inline: false
});