Server row model: master-detail

A detail panel under a leaf of a grouped server row model: the chevron on an order asks the model to open its detail (toggleDetail), the model puts a detail row under the leaf, the grid draws it through renderDetailRow at a height the virtualizer knows (detailRowHeight), and the panel fetches the order's line items from a second endpoint the moment it appears. The tree stays virtualized, and the region row holds under the header (stickyGroupRows) while the panels scroll past. (requires @svgrid/enterprise)

A live, editable Svelte 5 data grid example from the SvGrid gallery (Server-Side Row Model). See the SvGrid documentation for the full API.

About this example

Master-detail rows on a server-side row model in a Svelte 5 data grid. A chevron on an order asks the model to open its detail; the model places a detail display row under the leaf, the grid renders it through renderDetailRow at a fixed detailRowHeight the virtualizer understands, and the panel fetches the order's line items from a second endpoint the moment it appears, cached per order. The grouped tree stays virtualized end to end, and stickyGroupRows keeps the region row under the header while the panels scroll past.

A detail panel under a leaf of a grouped server row model. The grid's own toggle column (showDetailToggle) draws the chevron; the one on an order asks the model to open its detail (toggleDetail); the model puts a detail display row under the leaf, the grid draws it through renderDetailRow at detailRowHeight, and the panel fetches the order's line items from their own endpoint the moment it appears - with the grid virtualized over the whole tree, and the region row held under the header while the details scroll past.

The row model is Enterprise; the datasource contract the reference in-memory source implements is free.

Imports, features and API used

Imports: @svgrid/grid, @svgrid/enterprise, ../shared/mock-api

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: customer (Customer), status (Status), lines (Lines), amount (Amount), placed (Placed), sku (SKU), product (Product), qty (Qty), unitPrice (Unit price), total (Total)

Frequently asked questions

How does a detail row work when the rows live on the server?

The row model keeps the set of open details and inserts a display row of kind detail right under its leaf when it flattens the tree, carrying the leaf as master. The grid marks it with isDetailRow and draws it with renderDetailRow; nothing about the leaf is refetched.

Why does the panel need a fixed height?

The grid is virtualized over the whole tree, so it has to know the height of every row before it renders it. detailRowHeight tells the virtualizer what a detail row measures; content taller than that scrolls inside the panel. Without it a detail row is auto height and needs virtualization off.

When are the line items fetched?

When the panel renders, from its own endpoint, and the promise is kept per order, so closing and reopening a panel shows its lines at once and the request count in the footer does not grow.

Related documentation

Related articles

Source code (483-server-master-detail.svelte)

<!-- Documented in: docs/help/server/server-grouping.md -->
<script lang="ts">
  /**
   * 483. Server row model: master-detail
   * ------------------------------------
   * A detail panel under a leaf of a grouped server row model. The grid's own
   * toggle column (`showDetailToggle`) draws the chevron; the one
   * on an order asks the model to open its detail (`toggleDetail`); the
   * model puts a `detail` display row under the leaf, the grid draws it
   * through `renderDetailRow` at `detailRowHeight`, and the panel fetches
   * the order's line items from their own endpoint the moment it appears -
   * with the grid virtualized over the whole tree, and the region row held
   * under the header while the details scroll past.
   *
   * The row model is Enterprise; the datasource contract the reference
   * in-memory source implements is free.
   */
  import { SvGrid, renderComponent, tableFeatures, rowSortingFeature, columnFilteringFeature, type GridColumns } from '@svgrid/grid'
  import {
    setLicenseKey,
    installEnterprise,
    createInMemoryDataSource,
    createServerRowModel,
    serverGroupText,
    SvGroupCell,
    type EntitySchema,
    type ServerRowModel,
    type ServerRowModelState,
    type ServerRowModelGridRow,
  } from '@svgrid/enterprise'
  import { createPrng } from '../shared/mock-api'

  setLicenseKey('SVENTERPRISE-DEV-LOCAL')

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  // ---- The server ----------------------------------------------------------
  type Order = { id: number; region: string; customer: string; status: string; lines: number; amount: number; placed: string }
  type Line = { sku: string; product: string; qty: number; unitPrice: number; total: number }
  const REGIONS = ['EMEA', 'AMER', 'APAC']
  const CUSTOMERS = ['Acme', 'Globex', 'Initech', 'Umbrella', 'Hooli', 'Vandelay', 'Stark', 'Wayne', 'Tyrell', 'Wonka']
  const PRODUCTS = ['Bolt M8', 'Bracket L', 'Hinge 40', 'Panel 2x1', 'Rail 900', 'Clamp S', 'Washer 10', 'Pin 4', 'Strut 300']
  const STATUS = ['open', 'confirmed', 'shipped', 'shipped', 'invoiced']
  const rng = createPrng(0xd37a11)
  const DAY = 86_400_000
  const DB: Order[] = Array.from({ length: 4_000 }, (_, i) => {
    const lines = rng.int(1, 6)
    return {
      id: i + 1,
      region: rng.pick(REGIONS),
      customer: rng.pick(CUSTOMERS),
      status: rng.pick(STATUS),
      lines,
      amount: lines * rng.int(40, 900),
      placed: new Date(Date.now() - rng.int(1, 180) * DAY).toISOString().slice(0, 10),
    }
  })
  const schema: EntitySchema<Order> = {
    name: 'orders',
    fields: [
      { field: 'id', type: 'number', primaryKey: true },
      { field: 'region', type: 'text' },
      { field: 'customer', type: 'text' },
      { field: 'status', type: 'text' },
      { field: 'lines', type: 'number' },
      { field: 'amount', type: 'number' },
      { field: 'placed', type: 'date' },
    ],
  }
  const memory = createInMemoryDataSource(DB, schema)
  let requests = $state(0)
  let slow = $state(false)
  const source: typeof memory = {
    ...memory,
    async getRows(req) {
      requests += 1
      await new Promise((r) => setTimeout(r, 150))
      return memory.getRows(req)
    },
  }
  // The second endpoint: an order's line items, generated from the order id
  // on first request, so the same order always answers the same lines.
  let lineRequests = $state(0)
  const lineCache = new Map<number, Line[]>()
  async function fetchLines(orderId: number): Promise<Line[]> {
    await new Promise((r) => setTimeout(r, slow ? 900 : 250))
    // Counted once the call is on its way: the panel starts the fetch
    // from its markup, where state must not change mid-render.
    lineRequests += 1
    let lines = lineCache.get(orderId)
    if (!lines) {
      const order = DB.find((o) => o.id === orderId)
      const p = createPrng(orderId * 7919)
      const n = order?.lines ?? p.int(1, 6)
      lines = Array.from({ length: n }, (_, i) => {
        const qty = p.int(1, 40)
        const unitPrice = p.int(4, 120)
        return { sku: `SKU-${String(orderId).padStart(5, '0')}-${i + 1}`, product: p.pick(PRODUCTS), qty, unitPrice, total: qty * unitPrice }
      })
      lineCache.set(orderId, lines)
    }
    return lines
  }

  // One request per order, however often its panel re-renders: the promise
  // is kept, so a panel closed and opened again shows its lines at once.
  const linePromises = new Map<number, Promise<Line[]>>()
  function linesOf(orderId: number): Promise<Line[]> {
    let p = linePromises.get(orderId)
    if (!p) {
      p = fetchLines(orderId)
      linePromises.set(orderId, p)
    }
    return p
  }

  // ---- The model ---------------------------------------------------------
  type Row = ServerRowModelGridRow<Order>
  let view = $state<ServerRowModelState<Order>>()
  const ctl: ServerRowModel<Order> = createServerRowModel<Order>(source, {
    groupBy: ['region'],
    aggregations: [
      { col: 'amount', fn: 'sum' },
      { col: 'id', fn: 'count' },
    ],
    childCount: (r) => (r as { childCount?: number }).childCount,
    grandTotalRow: 'pinnedBottom',
    isGroupOpenByDefault: () => true,
    getRowId: (r) => String(r.id),
    blockSize: 100,
    skeletonRows: 4,
    filterValues: async (columnId) => [...new Set(DB.map((r) => String(r[columnId as keyof Order])))].sort(),
    onChange: (s) => (view = s),
  })
  ctl.refresh()
  $effect(() => () => ctl.dispose())

  // The details open: the set the model keeps, read from its state.
  const openCount = $derived(view?.openDetails.length ?? 0)
  function openFirstOnScreen(n: number) {
    // The first n loaded orders in the flattened list.
    const rows = ctl.getRows() as Row[]
    let opened = 0
    for (const r of rows) {
      if (r.__group?.kind !== 'leaf') continue
      if (!ctl.isDetailOpen(String(r.id))) ctl.toggleDetail(String(r.id), true)
      opened += 1
      if (opened >= n) break
    }
  }

  // ---- Columns --------------------------------------------------------------
  const usd = { type: 'number' as const, options: { style: 'currency' as const, currency: 'USD', maximumFractionDigits: 0 } }
  const isLeaf = (row: Row) => row.__group?.kind === 'leaf'
  // The chevron that opens a detail is the grid's own row-header column
  // (`showDetailToggle`); the model tells it which rows are leaves and open.
  const columns: GridColumns<Row> = [
    {
      id: 'region',
      header: 'Region / Order',
      width: 170,
      sortable: false,
      filterable: false,
      fieldFn: (row) => serverGroupText(row, 'id'),
      cell: (ctx) =>
        renderComponent(SvGroupCell, {
          row: ctx.row.original,
          onToggle: () => ctl.group.onToggle(ctx.row.original),
          leafField: 'id',
        }),
    },
    { field: 'customer', header: 'Customer', width: 130, editable: false },
    { field: 'status', header: 'Status', width: 100, editable: false },
    { field: 'lines', header: 'Lines', width: 70, align: 'right', editable: false, formatter: ({ value, row }) => (row && isLeaf(row.original) ? String(value) : '') },
    { field: 'amount', header: 'Amount', width: 110, align: 'right', format: usd, editable: false },
    { field: 'placed', header: 'Placed', width: 110, editable: false },
  ]
  const lineFeatures = tableFeatures({ rowSortingFeature })
  const lineColumns: GridColumns<Line> = [
    { field: 'sku', header: 'SKU', width: 140 },
    { field: 'product', header: 'Product', width: 120 },
    { field: 'qty', header: 'Qty', width: 60, align: 'right' },
    { field: 'unitPrice', header: 'Unit price', width: 100, align: 'right', format: usd },
    { field: 'total', header: 'Total', width: 100, align: 'right', format: usd },
  ]
  const orders = $derived(Number(view?.grandTotal?.id ?? 0))
</script>


<!-- The panel: the order's lines, fetched from their own endpoint when the
     row appears. The model hands the leaf over as `master`. -->
{#snippet Detail(props: { row: Row; rowIndex: number })}
  {@const master = (props.row.__group as { master?: Order }).master ?? (props.row as unknown as Order)}
  <div class="md-detail">
    <div class="md-detail-title">
      Order <strong>#{master.id}</strong> for <strong>{master.customer}</strong>
      <span class="md-detail-count">{master.lines} line{master.lines === 1 ? '' : 's'}</span>
      <span class="muted">{master.status}, placed {master.placed}</span>
    </div>
    {#await linesOf(master.id)}
      <div class="md-detail-loading" aria-busy="true">Loading lines from /orders/{master.id}/lines ...</div>
    {:then lines}
      <div class="md-detail-grid">
        <SvGrid responsive={true} data={lines} columns={lineColumns} features={lineFeatures} selectionMode="none" rowHeight={28} containerHeight="100%" fitColumns={true} virtualization={false} />
      </div>
    {/await}
  </div>
{/snippet}

<section class="wrap demo-kit">
  <header class="chrome">
    <div class="actions">
      <button type="button" class="btn" onclick={() => openFirstOnScreen(3)} title="Open the details of the first three loaded orders">Open 3</button>
      <button type="button" class="btn" disabled={!openCount} onclick={() => ctl.closeAllDetails()}>Close all details</button>
    </div>
    <label class="chk"><input type="checkbox" bind:checked={slow} /> Slow lines endpoint (900 ms)</label>
    <span class="note">
      Click the chevron on an order, or press Ctrl+Enter on its row: the model puts a detail row under
      it and the panel fetches that order's lines from a second endpoint. The tree stays virtualized,
      each panel has a fixed height the virtualizer knows, and the region row holds under the header
      while you scroll through.
    </span>
  </header>
  <div class="body">
    <div class="gridpane">
      <SvGrid
        responsive={true}
        columnResize
        fitColumns
        rowModel={ctl}
        stickyGroupRows
        showDetailToggle
        {columns}
        {features}
        sortable
        filterable
        filterMode="menu"
        selectionMode="none"
        rowHeight={32}
        detailRowHeight={200}
        isDetailRow={(row) => row.__group?.kind === 'detail'}
        renderDetailRow={Detail}
        containerHeight="100%"
        onApiReady={(next) => installEnterprise(next)}
      />
    </div>
  </div>
  <footer class="foot">
    <span class="stat"><span class="stat-label">Orders</span><strong>{orders.toLocaleString()}</strong></span>
    <span class="stat" data-stat="open"><span class="stat-label">Open details</span><strong>{openCount}</strong></span>
    <span class="stat" data-stat="lines"><span class="stat-label">Line requests</span><strong>{lineRequests}</strong></span>
    <span class="stat"><span class="stat-label">Requests</span><strong>{requests}</strong></span>
    {#if view?.error}<span class="stat err">{String((view.error as Error).message ?? view.error)}</span>{/if}
  </footer>
</section>

<style>
  /* Only what is particular to this demo; the chrome is the shared demo-kit. */
  .md-detail {
    border-left: 3px solid var(--sg-accent, #6366f1);
    background: color-mix(in oklab, var(--sg-accent, #6366f1) 4%, var(--sg-bg, #fff));
    padding: 10px 14px; width: 100%; box-sizing: border-box; height: 100%;
    display: flex; flex-direction: column; gap: 8px;
  }
  .md-detail-title {
    font-size: 12px; color: var(--sg-muted, #64748b);
    display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  }
  .md-detail-title strong { color: var(--sg-fg, #0f172a); }
  .md-detail-count {
    font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
    background: color-mix(in oklab, var(--sg-accent, #6366f1) 14%, transparent); color: var(--sg-accent, #6366f1);
    padding: 1px 7px; border-radius: 999px;
  }
  .md-detail-loading { font-size: 12px; color: var(--sg-muted, #64748b); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
  .md-detail-grid {
    flex: 1; min-height: 0;
    border: 1px solid var(--sg-border, #e2e8f0); border-radius: 6px; overflow: hidden;
    background: var(--sg-bg, #fff);
  }
</style>

View this example on GitHub

More Server-Side Row Model examples

  • Server-Side Row Model: 1,000,000 rows - One grid, one rowModel prop, a million rows that stay on the server. Sort, filter, global search, grouping to any depth (Region > Country > Rep), infinite scroll or paging, inline edits applied back as transactions with the subtotal following, add and delete, select-all across rows the grid never loaded with a bulk edit by rule, failed blocks with Retry, a request log that shows every call to the columnar warehouse behind it, and a live map of the block cache per level. The row model ships in @svgrid/enterprise; the datasource contract is free.
  • Server-side pivot - The pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request. The backend answers with one field per pivot key and aggregation and lists them in pivotResultFields; the model builds the column groups from that list. Apply / Cancel hold a slice-and-dice session to one request, a Total column group carries the row totals, and a grand total row is pinned at the bottom.
  • 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.
  • 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.
  • Server transactions (live feed) - A socket-style feed of changes the server already made, applied without a refetch: a price tick patches the loaded row in place with a flash (updateRowData), a new order lands at the top of its warehouse and a shipped one leaves (applyTransactionAsync, batched every 500 ms, addressed by route). Every result carries a status the log shows: applied, cancelled under the veto hook, storeNotFound for a warehouse whose level is not cached. Refresh totals recomputes the sums a transaction leaves alone.