Server-side data (load on demand)
When the rows live behind an API and there are too many to ship to the browser, the server does the work: sorting, filtering, and paging become query params, and it returns only the current page plus a total count. The headless engine wraps that page and nothing else - it never sees the rest of the dataset.
Try it: search, click a header to sort, page through. Each change fires exactly one request (watch the counter climb):
The shape of it
There is no local pipeline here - just coreRowModel. You own three things:
- The query state (
pageIndex,pageSize,sort,desc,q) - the inputs your API takes. - A fetch that re-runs when that state changes - an
$effectis enough. - The engine, wrapping only the page you got back.
<script lang="ts">
import { createSvGrid, createCoreRowModel, type ColumnDef } from '@svgrid/grid'
type Row = { id: number; name: string; dept: string; salary: number }
// 1. Query state - the inputs your endpoint takes.
const pageSize = 8
let pageIndex = $state(0)
let sort = $state<'name' | 'dept' | 'salary'>('name')
let desc = $state(false)
let q = $state('')
// Server response.
let pageRows = $state<Row[]>([])
let total = $state(0)
let loading = $state(false)
let reqSeq = 0
// 2. Re-fetch whenever any query input changes. The sequence guard drops
// stale responses if a newer request resolves first (race-safe).
$effect(() => {
const query = { pageIndex, pageSize, sort, desc, q }
const mine = ++reqSeq
loading = true
fetch(`/api/employees?${new URLSearchParams(query as never)}`)
.then((r) => r.json())
.then((res: { rows: Row[]; total: number }) => {
if (mine !== reqSeq) return
pageRows = res.rows
total = res.total
loading = false
})
})
// 3. The engine wraps ONLY the returned page - no filtered/sorted/paginated
// row model, because the server already did all of that.
const columns: ColumnDef<Record<string, never>, Row>[] = [
{ field: 'name', header: 'Name' },
{ field: 'dept', header: 'Department' },
{ field: 'salary', header: 'Salary' },
]
const table = $derived.by(() =>
createSvGrid({
_rowModels: { coreRowModel: createCoreRowModel<Row>() },
data: pageRows,
columns,
} as never),
)
const rows = $derived(table.getRowModel().rows)
const pageCount = $derived(Math.max(1, Math.ceil(total / pageSize)))
</script>
Render rows however you like, and drive the query state from your controls:
<input value={q} oninput={(e) => { q = e.currentTarget.value; pageIndex = 0 }} />
<table>
<thead>
<tr>
{#each columns as col (col.field)}
<th onclick={() => {
if (sort === col.field) desc = !desc
else { sort = col.field; desc = false }
pageIndex = 0
}}>
{col.header}{sort === col.field ? (desc ? ' ▼' : ' ▲') : ''}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as r (r.id)}
{@const row = r.original as Row}
<tr><td>{row.name}</td><td>{row.dept}</td><td>{row.salary}</td></tr>
{/each}
</tbody>
</table>
<button onclick={() => (pageIndex -= 1)} disabled={pageIndex === 0 || loading}>Prev</button>
Page {pageIndex + 1} of {pageCount}
<button onclick={() => (pageIndex += 1)} disabled={pageIndex >= pageCount - 1 || loading}>Next</button>
Why only coreRowModel?
Because the pipeline already ran - on the server. Adding
filteredRowModel / sortedRowModel / paginatedRowModel would make the engine
try to filter, sort and slice the page you already fetched, which is wrong: it
only has 8 of the 483 rows. Feed the engine the finished page and let it do the
one job left - wrap each item as a Row so your markup and any cell logic work
the same as everywhere else.
Three things worth getting right
- Reset the page on a new sort or filter. A different sort or query produces a
different result set, so
pageIndex = 0- otherwise you can land on an out-of-range page. - Guard against out-of-order responses. Fast typing fires overlapping
requests; without the
reqSeqcheck an early response can overwrite a later one. Increment a sequence number per request and ignore any response that is not the latest. - Debounce the search if each keystroke hits a real network. A short
setTimeoutonqkeeps you from firing a request per character.
See also
- Row models - the local pipeline, for when the client owns the data
- Controlled state -
statein,onXxxChangeout - Build a table from scratch - the rendering half
- Server-side data (with
<SvGrid>) - the same idea, wired for you