Server tree data (load on demand) - Enterprise
When a hierarchy is too large to ship up front - a file system, an org chart with tens of thousands of people, a geographic drill-down - you do not want to fetch every node before the grid renders. The answer is to seed only the roots, then fetch each node's children the first time the user expands it.
This page covers self-referential tree data (a node's children are more
rows of the same shape, found by parentId). That is still a pattern you
assemble over the grid's data prop: you keep a flat allNodes array plus an
expanded map, derive the visible rows, and run an async toggle(id) that
fetches, appends, and caches. If that flat-tree derivation is new to you, read
Tree data first - this page is its lazy-loading
extension.
First-class tree mode
A file tree the grid never holds whole, each folder read on first expand:
Open the live example: Server tree data (row model) (Server-Side Row Model)
createServerRowModel handles self-referential trees too - turn on
treeData and give it getRowId (the node id) and hasChildren (whether a
node can expand). It owns the lazy expand/collapse, a block cache per node,
refresh and retry per route, and race-safety; each expand calls getRows
with groupKeys set to the path of node ids, and you return that node's
direct children, one block at a time:
The examples on this page run against these rows:
<script lang="ts">
import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'
type Person = {
id: number
name: string
department: string
city: string
age: number
salary: number
}
const people: Person[] = [
{ id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },
{ id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },
{ id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },
{ id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },
{ id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },
]
const columns: GridColumns<Person> = [
{ field: 'name', header: 'Name', width: 190 },
{ field: 'department', header: 'Department', width: 150 },
{ field: 'city', header: 'City', width: 130 },
{ field: 'age', header: 'Age', width: 80 },
{ field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },
]
</script>
<script lang="ts">
import { SvGrid, renderComponent } from '@svgrid/grid'
import { createServerRowModel, SvGroupCell } from '@svgrid/enterprise'
type Node = { id: string; name: string; expandable: boolean }
const ctl = createServerRowModel<Node>(source, {
treeData: true,
getRowId: (n) => n.id,
hasChildren: (n) => n.expandable,
isGroupOpenByDefault: (route) => route.length < 2, // the first two levels open on arrival
})
ctl.refresh() // load the roots (getRows with groupKeys: [])
const columns = [
{ id: 'name', header: 'Name',
cell: (ctx) => renderComponent(SvGroupCell, { row: ctx.row.original, onToggle: () => ctl.group.onToggle(ctx.row.original), leafField: 'name' }) },
]
</script>
<SvGrid rowModel={ctl} {columns} />
The source's getRows returns a node's children for the requested path; a row
is expandable when hasChildren returns true. rowModel wires the rows, the
treegrid keyboard (ArrowRight / ArrowLeft), the visible range and the
placeholder rows; refresh({ route }) reloads one node's children in place,
applyRowData fills a node from a payload that shipped with its parent, and
transactions add or remove children without a
request. See Server grouping for the full contract
and the aggregate case. The rest of this page is the roll-your-own
alternative, for when you want to own the flat-row derivation yourself.
Moving rows
A file dragged onto a folder is a move on the server, not a reorder of the
rows the grid holds. Two pieces do it. The grid prop onRowDrop hands you
every drop instead of touching the data: the dragged row, the row it landed
on (or null below the last one), that row's display index, and a side.
Over the top or bottom edge the side is before or after; over the middle
of a group row it is into, and the grid tints the whole row rather than
drawing a line, so the user sees the difference. The middle band exists
only when onRowDrop is set. The model method moveRow(id, toRoute, { patch?, addIndex? }) then writes the move through the source's
updateRow when you give it a patch, takes the row out of the level it
was in and puts it into toRoute at addIndex (the end when omitted):
<SvGrid
rowModel={ctl}
{columns}
rowDragManaged
onRowDrop={async ({ row, target, side }) => {
const t = target?.__group
// Into a folder: its path. Beside a row: that row's folder.
const toRoute = !t ? [] : side === 'into' && t.kind === 'group' ? [...t.path] : t.kind === 'group' ? t.path.slice(0, -1) : [...(t.route ?? [])]
const parent = toRoute.at(-1) ?? 'root'
await ctl.moveRow(row.id, toRoute, { patch: { parent } })
}}
/>
The result carries the row the server returned and the status of the add.
A target folder nothing has opened has no cached level to add to, so the
status is storeNotFound: the row is gone from its old level, the closed
folder's childCount badge already moved, and the folder shows the row
when it opens. Demo 469 above expands the folder in that case. moveRow
rejects a row that is not loaded; a folder moves with its subtree (the
cached levels under its old key are dropped and re-read on expand). Rows
dragged from another grid still go through onRowDragEnd / rowDropTarget.
Open the live example: Lazy tree (load on expand) (Tree & Hierarchy)
The moving parts
Four pieces of state, and one async action:
allNodes- a flat array of every node loaded so far. It starts as just the roots and grows as the user expands branches.expanded- aRecord<string, boolean>of which node ids are open.- each node carries a
loadState: 'unloaded' | 'loading' | 'loaded'and anexpandableflag, so you know whether a fetch is still needed. visibleRows- derived: a depth-first walk from the roots that stops descending at collapsed nodes and injects a placeholder row while a node is loading.toggle(id)- the async action that expands/collapses and fetches.
Seeding the roots
Load only the top level into allNodes. Every seeded node is marked
unloaded so the first expand triggers a fetch:
type LoadState = 'unloaded' | 'loading' | 'loaded'
type Node = {
id: string
parentId: string | null
depth: number
name: string
expandable: boolean
loadState: LoadState
childIds: string[]
}
let allNodes = $state<Node[]>(await fetchChildren('root')) // roots only
let expanded = $state<Record<string, boolean>>({})
fetchChildren(id) is your function - a fetch() to your API, a SQL
call in a SvelteKit endpoint, whatever. It returns that node's direct
children, each seeded unloaded with an empty childIds.
Toggle: fetch on first expand, cache after
toggle flips the expanded flag, and - only when opening a node that has
not been loaded yet - sets loadState: 'loading', awaits
fetchChildren, then marks it 'loaded' and appends the children to the
flat array. A node already 'loaded' never fetches again, so re-expand
is instant:
async function toggle(id: string) {
const isOpen = !!expanded[id]
expanded = { ...expanded, [id]: !isOpen }
if (isOpen) return // collapsing - nothing to fetch
const node = allNodes.find((n) => n.id === id)
if (!node || !node.expandable || node.loadState === 'loaded') return // cached
allNodes = allNodes.map((n) => (n.id === id ? { ...n, loadState: 'loading' } : n))
try {
const children = await fetchChildren(id)
allNodes = allNodes
.map((n) =>
n.id === id
? { ...n, loadState: 'loaded', childIds: children.map((c) => c.id) }
: n,
)
.concat(children)
} catch (err) {
// roll back so the user can retry
allNodes = allNodes.map((n) => (n.id === id ? { ...n, loadState: 'unloaded' } : n))
console.error('fetchChildren failed', err)
}
}
Two things worth calling out:
- Immutable updates. Each step reassigns
allNodes/expandedso Svelte's$statereactivity fires. Never mutate a node in place. - The
catchrollsloadStateback to'unloaded', so a failed request leaves the node collapsible and retryable instead of stuck on a spinner.
The visible rows and the loading placeholder
Derive what the grid renders. Walk from the roots; at an expanded node
that is still loading, push a synthetic placeholder row instead of
descending:
type ViewRow = Node | { id: string; parentId: string; depth: number; placeholder: true }
const visibleRows = $derived.by(() => {
const out: ViewRow[] = []
const byId = new Map(allNodes.map((n) => [n.id, n]))
function walk(id: string) {
const node = byId.get(id)
if (!node) return
out.push(node)
if (!expanded[id]) return
if (node.loadState === 'loading') {
out.push({ id: `${id}__loading`, parentId: id, depth: node.depth + 1, placeholder: true })
return
}
for (const cid of node.childIds) walk(cid)
}
for (const root of allNodes.filter((n) => n.parentId === null)) walk(root.id)
return out
})
Hand visibleRows to <SvGrid data={visibleRows} ...> like any other
dataset. Render the placeholder as a spinner row inside the name-column
cell snippet (see Tree data for the chevron +
indentation cell), keying off a placeholder type guard.
Keyboard navigation
Give the tree column standard tree-grid keys. Intercept at the window level with a capture listener so your handler runs before the grid's own arrow-key cell mover, and only act when the active cell is on the name column:
let activeCol = $state('')
let activeRowIndex = $state(0)
$effect(() => {
function onKey(e: KeyboardEvent) {
if (activeCol !== 'name') return
const row = visibleRows[activeRowIndex]
if (!row || 'placeholder' in row || !row.expandable) return
const isOpen = !!expanded[row.id]
if (e.key === 'ArrowRight' && !isOpen) { e.preventDefault(); void toggle(row.id) }
else if (e.key === 'ArrowLeft' && isOpen) { e.preventDefault(); void toggle(row.id) }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); void toggle(row.id) }
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
})
- Right expands a collapsed node (firing the fetch on first open).
- Left collapses an expanded one.
- Enter / Space toggle.
Track the active cell by wiring onActiveCellChange on the grid:
<SvGrid
data={visibleRows}
{columns}
onActiveCellChange={(args) => { activeCol = args.columnId; activeRowIndex = args.rowIndex }}
/>
Regular arrow keys still move the active cell on non-tree columns - the tree keys only fire when focus is on the name column.
Why this stays a pattern
Because the tree lives entirely in your derived state, everything else -
sorting, custom cells, roll-up totals, styling - is the same code you
would write for a fully-seeded tree. The only difference is that
allNodes grows over time. There is no server-tree mode to configure and
no contract to satisfy; you own the fetch, the cache, and the shape of
the rows.
More examples
Server grouping (row model)
Server-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.
Open the live example: Server grouping (row model) (Server-Side Row Model)
Try it
Server tree data is the same flat parent/child shape as the client version - the only difference is where the rows come from. Loading a branch means appending its children to the array you already hold.
<script lang="ts">
type Node = { id: string; parentId: string | null; name: string; size: string }
// What the server has. A real one returns only the requested branch.
const store: Node[] = [
{ id: 'src', parentId: null, name: 'src', size: '-' },
{ id: 'src/app', parentId: 'src', name: 'app.ts', size: '4 KB' },
{ id: 'src/lib', parentId: 'src', name: 'lib', size: '-' },
{ id: 'src/lib/a',parentId: 'src/lib', name: 'api.ts', size: '9 KB' },
{ id: 'pub', parentId: null, name: 'public', size: '-' },
{ id: 'pub/ico', parentId: 'pub', name: 'favicon.ico', size: '1 KB' },
]
// Start with the roots; "fetch" pulls a branch in on demand.
let loaded = $state<Node[]>(store.filter((n) => n.parentId === null))
function load(parentId: string) {
const kids = store.filter((n) => n.parentId === parentId)
const have = new Set(loaded.map((n) => n.id))
loaded = [...loaded, ...kids.filter((k) => !have.has(k.id))]
}
const cols: GridColumns<Node> = [
{ field: 'name', header: 'Name', width: 260 },
{ field: 'size', header: 'Size', width: 100 },
]
</script>
<div>
<button type="button" onclick={() => load('src')}>Load src/</button>
<button type="button" onclick={() => load('src/lib')}>Load src/lib/</button>
<button type="button" onclick={() => load('pub')}>Load public/</button>
</div>
<SvGrid
data={loaded}
columns={cols}
treeData={{ parentField: 'parentId', idField: 'id', column: 'name' }}
/>
See also
- Server-Side Row Model - the page-based datasource contract for flat data that lives on the server.
- Server grouping - the sibling pattern: the backend returns pre-aggregated group rows and expanding one drills into its detail rows.
- Tree data - the flat-list + derived-visible-rows foundation this page extends, including the chevron cell and roll-ups.
Live examples
- Server tree data (row model) - A file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, answered with that folder's entries one block at a time. createServerRowModel in treeData mode owns the lazy expand, a block cache per folder, open-by-default, expand and collapse all, a per-folder refresh that re-reads one folder in place, and transactions that add or delete a file without a refetch. The server generates each folder from a seeded PRNG on first request, five levels deep.
- Lazy tree (load on expand) - Region → Country → State → City. Children fetched async on first expand with a "Loading…" placeholder row; subtrees cached on second expand.
- Server grouping (row model) - Server-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.
Related articles
- Lazy-Loading Master-Detail Content in SvGrid - Fetch detail-panel data only when a row is expanded, cache the results, and cancel abandoned requests - keeping a large grid fast without paying for panels no one views.
- Going AI-Native: The SvGrid MCP Server - Most AI assistants invent data grid APIs. We built an MCP server so they look up the real one instead.
- Empty, Loading, and Error States for a Svelte Data Grid - How to handle the three non-data states in a Svelte grid - empty (with two subtypes), skeleton loading that does not flash, and recoverable errors that keep existing rows visible.