Real-time / streaming updates

How to drive the grid from a WebSocket / SSE / poll. Three patterns ranked by the rate of change:

  1. Periodic full refresh - poll for the latest rows, swap the array.
  2. Cell flash on change - the same swap, but the renderer highlights cells whose values just changed.
  3. Delta merge with backlog - WebSocket pushes individual row patches; you merge them into the in-memory state, optionally batching while the user has the page paused.

A live WebSocket or SSE feed pushes deltas that apply as add, update, or remove operations, run through the grid as a keyed row transaction, and surface as a cell flash on the changed row.

Try a streaming order desk - cell flashes on every change, pause / resume, disconnect / reconnect, configurable throughput slider:

Open the live example: Real-time / streaming (Real-time & Streaming)

Pattern 1: periodic full refresh

The simplest reactive pattern. Poll every N seconds, hand the new array down.

Every example below runs against this setup:

<script lang="ts">
  import { SvGrid, tableFeatures, rowSortingFeature, type ColumnDef } from '@svgrid/grid'

  type Order = { id: string; symbol: string; qty: number; price: number; updatedAt: number }

  const features = tableFeatures({ rowSortingFeature })

  const fmtMoney = (n: number) =>
    n.toLocaleString('en-US', { style: 'currency', currency: 'USD' })

  let rows = $state<Order[]>([
    { id: 'o1', symbol: 'AAPL', qty: 120, price: 231.4, updatedAt: Date.now() },
    { id: 'o2', symbol: 'MSFT', qty: 80,  price: 418.9, updatedAt: Date.now() },
    { id: 'o3', symbol: 'NVDA', qty: 45,  price: 902.1, updatedAt: Date.now() },
  ])
  const data = rows

  const columns: ColumnDef<typeof features, Order>[] = [
    { field: 'symbol', header: 'Symbol', width: 110 },
    { field: 'qty',    header: 'Qty',    width: 90 },
    { field: 'price',  header: 'Price',  width: 120, format: { type: 'currency', currency: 'USD' } },
  ]
</script>
<script lang="ts">
  let rows = $state<Order[]>([])

  $effect(() => {
    const id = setInterval(async () => {
      rows = await fetch('/api/orders').then((r) => r.json())
    }, 5_000)
    return () => clearInterval(id)
  })
</script>

<SvGrid data={rows} columns={columns} features={features} />

The grid re-renders the visible rows; virtualization keeps the cost proportional to the viewport, not the dataset.

Use when: dataset is small (< 1000 rows), update rate is low (≥ 5 s), "freshness" is the only requirement.

Avoid when: the user is mid-edit on a cell. A full swap mid-edit will close the editor. Pause your refresh while onActiveCellChange reports a non-null active cell.

Pattern 2: cell flash on change

Same swap, but each cell snippet tracks its previous value and renders a brief highlight when it differs.

<script lang="ts">
  // Track per-row, per-field last-seen values.
  let lastSeen = new Map<string, Record<string, unknown>>()
  function diff(rowId: string, current: Record<string, unknown>): Record<string, boolean> {
    const prev = lastSeen.get(rowId)
    const changed: Record<string, boolean> = {}
    if (prev) for (const k of Object.keys(current)) if (prev[k] !== current[k]) changed[k] = true
    lastSeen.set(rowId, { ...current })
    return changed
  }
</script>

{#snippet PriceCell(props: { row: Order })}
  {@const changes = diff(props.row.id, props.row)}
  <span class={`tabular-nums ${changes.price ? 'flash' : ''}`}>
    {fmtMoney(props.row.price)}
  </span>
{/snippet}
.flash {
  animation: flash-bg 800ms ease-out;
}
@keyframes flash-bg {
  from { background: rgba(250, 204, 21, 0.4); }
  to   { background: transparent; }
}

Use when: user wants to spot changes. The stock-ticker pattern.

Avoid when: the flash interferes with selection or accessibility - add prefers-reduced-motion guards if the flash is purely decorative.

Pattern 3: delta merge with backlog

The grown-up pattern for higher-rate updates. The server pushes individual row patches; you maintain an in-memory map and reassign the array when needed.

<script lang="ts">
  type OrderId = string
  let rowsMap = $state(new Map<OrderId, Order>())
  let paused = $state(false)
  let backlog = $state<Order[]>([])
  const flushDebounce = 200  // ms

  const rows = $derived(Array.from(rowsMap.values()))

  $effect(() => {
    const ws = new WebSocket('/api/orders/stream')
    ws.onmessage = (e) => {
      const patch: Order = JSON.parse(e.data)
      if (paused) {
        backlog = [...backlog, patch]
        return
      }
      applyPatch(patch)
    }
    return () => ws.close()
  })

  function applyPatch(patch: Order) {
    const next = new Map(rowsMap)
    next.set(patch.id, patch)
    rowsMap = next
  }

  function resume() {
    paused = false
    for (const p of backlog) applyPatch(p)
    backlog = []
  }
</script>

<button onclick={() => (paused = !paused)}>
  {paused ? `Resume (${backlog.length} pending)` : 'Pause'}
</button>

<SvGrid data={rows} columns={columns} features={features} />

A few production knobs:

Pause during edit / selection

A common mistake: updates fly in while the user is mid-paste or mid-edit. The fix is two flags:

let isEditing = $state(false)
let hasSelection = $state(false)
const isInteracting = $derived(isEditing || hasSelection)

$effect(() => {
  if (isInteracting) {
    paused = true
  } else if (paused) {
    resume()
  }
})

Wire to the grid:

<SvGrid
  ...
  onCellDoubleClick={() => (isEditing = true)}
  onCellValueChange={() => (isEditing = false)}
  onRowSelectionChange={(selection, rows) => (hasSelection = rows.length > 0)}
/>

onRowSelectionChange receives two positional arguments - the { [rowId]: true } record and the array of selected rows.

There is no dedicated editing-state callback today, so the edit flag is assembled from the two ends of the edit: a double-click opens the editor, and a committed value closes it. That leaves one gap - an edit abandoned with Escape commits nothing, so clear the flag on the wrapper's onkeydown too:

<div onkeydown={(e) => { if (e.key === 'Escape') isEditing = false }}>
  <SvGrid ... />
</div>

If your app starts edits itself through api.startEditing() / api.stopEditing(), set the flag at those call sites instead - you already know the state there, and it covers every exit path.

Backpressure (when the server is too fast)

If your producer can outrun the UI's frame budget, sample down at the client. Drop intermediate patches for the same row; only the newest one survives:

function applyPatch(patch: Order) {
  // pendingPatches is keyed by row id so a fast-moving row only
  // commits its NEWEST value per frame.
  pendingPatches.set(patch.id, patch)
  /* ...schedule rAF... */
}

For 1000 rows updating at 5 Hz, this caps the work at 1000 assignments per frame regardless of the actual message rate. The streaming demo's "throughput slider" stresses this exact path.

Combining with sort + filter

The grid's sort + filter run AFTER your patches land in rows. Two implications:

If you're using a side detail panel keyed on row id (not index), this is a non-issue - the detail stays bound to the order even as the row moves.

Frequently asked questions

How do I show real-time data in SvGrid?

Drive the grid's data from a WebSocket, SSE, or poll. This page covers three patterns ranked by update rate: periodic full refresh, targeted row patches via getRowId, and high-frequency cell updates with change-flash highlighting.

Will the grid keep my scroll and selection on live updates?

Yes, if you give rows a stable identity with getRowId. Then selection, expansion, edit state, and scroll position survive incoming updates instead of resetting on every refresh.

How fast can SvGrid update?

Fast enough for tick-by-tick feeds - the stock-market demo updates 25 symbols every 250 ms with green/red cell flashes while sorting and selection stay live. For very high rates, patch only changed rows rather than swapping the whole array.

See also

Related articles