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>:

Open the live example: Row models are a pipeline (Headless)

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>

See also