Row models
The engine turns your raw data into the rows you render by running it through
a pipeline of row models. You opt into the steps you need; everything else
is tree-shaken out.
data
└─▶ coreRowModel wrap each item as a Row
└─▶ filteredRowModel drop rows that fail the column filters
└─▶ sortedRowModel order by the sort state
└─▶ groupedRowModel build group parent rows + aggregates
└─▶ expandedRowModel flatten only expanded groups
└─▶ paginatedRowModel slice to the current page
Each step is lazy and reads from the controlled state
(sorting, columnFilters, grouping, expanded, pagination).
Flip the group-by control below and watch the pipeline change shape - the same
engine goes from a flat list to grouped -> expanded with per-group sum
aggregates, and the markup stays a plain hand-styled <table>:
Opt in per step
Pass only the models you want into _rowModels. createCoreRowModel is always
required (it's the entry point); the rest are optional.
import {
createSvGrid,
createCoreRowModel,
createFilteredRowModel,
createSortedRowModel,
createPaginatedRowModel,
createGroupedRowModel,
createExpandedRowModel,
tableFeatures,
rowPaginationFeature,
columnGroupingFeature,
rowExpandingFeature,
} from '@svgrid/grid'
const table = createSvGrid({
_features: features,
_rowModels: {
coreRowModel: createCoreRowModel<Row>(),
filteredRowModel: createFilteredRowModel<Row>(),
sortedRowModel: createSortedRowModel<Row>(),
paginatedRowModel: createPaginatedRowModel<Row>(),
},
data,
columns,
state,
// ...change handlers
})
const rows = table.getRowModel().rows // filtered → sorted → paged
Skip paginatedRowModel and getRowModel().rows returns every matching row.
Skip filteredRowModel and the columnFilters state is simply ignored.
Pagination
Add createPaginatedRowModel and drive it with pagination state:
let pagination = $state({ pageIndex: 0, pageSize: 20 })
const features = tableFeatures({ rowPaginationFeature })
const table = $derived.by(() => createSvGrid({
_features: features,
_rowModels: {
coreRowModel: createCoreRowModel<Row>(),
paginatedRowModel: createPaginatedRowModel<Row>(),
},
data, columns,
state: { pagination },
onPaginationChange: (u) =>
(pagination = typeof u === 'function' ? u(pagination) : u),
}))
// getRowModel().rows is now just the current page
function nextPage() { pagination = { ...pagination, pageIndex: pagination.pageIndex + 1 } }
Grouping + aggregation
groupedRowModel builds parent rows with aggregated values; expandedRowModel
then emits only the expanded children. A grouped row's getIsGrouped() is true
and its aggregates come from each column's aggregate setting.
let grouping = $state<string[]>(['lang'])
let expanded = $state<Record<string, boolean>>({})
const features = tableFeatures({ columnGroupingFeature, rowExpandingFeature })
const table = $derived.by(() => createSvGrid({
_features: features,
_rowModels: {
coreRowModel: createCoreRowModel<Repo>(),
groupedRowModel: createGroupedRowModel<Repo>(),
expandedRowModel: createExpandedRowModel<Repo>(),
},
data,
columns: [
{ field: 'lang', header: 'Lang' },
{ field: 'stars', header: 'Stars', aggregate: 'sum' }, // rolled up per group
],
state: { grouping, expanded },
onExpandedChange: (u) => (expanded = typeof u === 'function' ? u(expanded) : u),
}))
const rows = $derived(table.getRowModel().rows)
// each row: row.getIsGrouped(), row.getIsExpanded(), row.toggleExpanded()
Server-side: skip the local pipeline
When the server does the sorting / filtering / paging, feed the engine only the page it returned and leave those models out - the engine just wraps and renders what you give it:
const table = createSvGrid({
_features: tableFeatures({}), // no features - the server did the work
_rowModels: { coreRowModel: createCoreRowModel<Row>() }, // core only
data: serverPage.rows, // already sorted/filtered/paged by the API
columns,
state,
})
Track the sort/filter state via the change handlers and re-fetch on change - see Server-side data (load on demand) for a complete, runnable example with paging, sorting, filtering and a race-safe fetch.
Pagination, running
paginatedRowModel is the last stage, so getRowModel().rows is already the
current page - there is no slicing left for you to do. Page state is yours, which
is why the buttons below just assign to it.
<script lang="ts">
import {
createSvGrid,
createCoreRowModel,
createPaginatedRowModel,
tableFeatures,
rowPaginationFeature,
type ColumnDef,
} from '@svgrid/grid'
type Repo = { name: string; lang: string; stars: number }
const data: Repo[] = [
{ name: 'svelte', lang: 'JavaScript', stars: 78000 },
{ name: 'vite', lang: 'TypeScript', stars: 68000 },
{ name: 'sv-grid', lang: 'TypeScript', stars: 172 },
{ name: 'rollup', lang: 'JavaScript', stars: 25000 },
{ name: 'esbuild', lang: 'Go', stars: 38000 },
{ name: 'tinygo', lang: 'Go', stars: 15000 },
{ name: 'bun', lang: 'Zig', stars: 74000 },
{ name: 'zig', lang: 'Zig', stars: 35000 },
]
const features = tableFeatures({ rowPaginationFeature })
const columns: ColumnDef<typeof features, Repo>[] = [
{ field: 'name', header: 'Repo' },
{ field: 'lang', header: 'Language' },
{ field: 'stars', header: 'Stars' },
]
let pagination = $state({ pageIndex: 0, pageSize: 3 })
const table = $derived.by(() =>
createSvGrid({
_features: features,
_rowModels: {
coreRowModel: createCoreRowModel<Repo>(),
paginatedRowModel: createPaginatedRowModel<Repo>(),
},
data,
columns,
state: { pagination },
onPaginationChange: (u) => (pagination = typeof u === 'function' ? u(pagination) : u),
}),
)
const rows = $derived(table.getRowModel().rows)
const pages = $derived(Math.ceil(data.length / pagination.pageSize))
function go(delta: number) {
const next = Math.min(pages - 1, Math.max(0, pagination.pageIndex + delta))
pagination = { ...pagination, pageIndex: next }
}
</script>
<div>
<button type="button" onclick={() => go(-1)} disabled={pagination.pageIndex === 0}>Prev</button>
<span>Page {pagination.pageIndex + 1} of {pages}</span>
<button type="button" onclick={() => go(1)} disabled={pagination.pageIndex >= pages - 1}>Next</button>
</div>
<ul>
{#each rows as r (r.id)}
{@const repo = r.original as Repo}
<li>{repo.name} - {repo.lang} - {repo.stars.toLocaleString()}</li>
{/each}
</ul>
Grouping, running
groupedRowModel inserts parent rows; expandedRowModel decides which
children survive. A parent is the row where getCanExpand() is true, and its
aggregated cells come from the column's aggregate setting - so a group row and
a leaf row are two different shapes in the same list, and your markup has to
branch on it.
<script lang="ts">
import {
createSvGrid,
createCoreRowModel,
createGroupedRowModel,
createExpandedRowModel,
tableFeatures,
columnGroupingFeature,
rowExpandingFeature,
type ColumnDef,
} from '@svgrid/grid'
type Repo = { name: string; lang: string; stars: number }
const data: Repo[] = [
{ name: 'svelte', lang: 'JavaScript', stars: 78000 },
{ name: 'vite', lang: 'TypeScript', stars: 68000 },
{ name: 'sv-grid', lang: 'TypeScript', stars: 172 },
{ name: 'rollup', lang: 'JavaScript', stars: 25000 },
{ name: 'esbuild', lang: 'Go', stars: 38000 },
{ name: 'tinygo', lang: 'Go', stars: 15000 },
{ name: 'bun', lang: 'Zig', stars: 74000 },
{ name: 'zig', lang: 'Zig', stars: 35000 },
]
const features = tableFeatures({ columnGroupingFeature, rowExpandingFeature })
const columns: ColumnDef<typeof features, Repo>[] = [
{ field: 'name', header: 'Repo' },
{ field: 'lang', header: 'Language' },
{ field: 'stars', header: 'Stars', aggregate: 'sum' },
]
let grouping = $state<string[]>(['lang'])
let expanded = $state<Record<string, boolean>>({})
const table = $derived.by(() =>
createSvGrid({
_features: features,
_rowModels: {
coreRowModel: createCoreRowModel<Repo>(),
groupedRowModel: createGroupedRowModel<Repo>(),
expandedRowModel: createExpandedRowModel<Repo>(),
},
data,
columns,
state: { grouping, expanded },
onExpandedChange: (u) => (expanded = typeof u === 'function' ? u(expanded) : u),
}),
)
const rows = $derived(table.getRowModel().rows)
// Feature-conditional members: present because rowExpandingFeature is on.
const isGroup = (r: any) => typeof r.getCanExpand === 'function' && r.getCanExpand()
</script>
<table>
<tbody>
{#each rows as r (r.id)}
{#if isGroup(r)}
<tr onclick={() => (r as any).toggleExpanded?.()} style="cursor: pointer; font-weight: 600;">
<td colspan="2">
{(r as any).getIsExpanded?.() ? '- ' : '+ '}{(r as any).getCellValueByColumnId('lang')}
</td>
<td>{Number((r as any).getCellValueByColumnId('stars') ?? 0).toLocaleString()}</td>
</tr>
{:else}
{@const repo = r.original as Repo}
<tr>
<td style="padding-left: 18px;">{repo.name}</td>
<td>{repo.lang}</td>
<td>{repo.stars.toLocaleString()}</td>
</tr>
{/if}
{/each}
</tbody>
</table>