# Live updates

> Stream a market feed into the grid — plain rows patched in place, one batch a frame, with rows that keep their sort, go stale when a feed goes quiet and flash when they arrive.

bo-grid is built for screens whose values change many times a second: price boards, watchlists, blotters. Values change in place, and the grid repaints only the cells that changed, flashing each one. Updates can reach the grid two ways. Write into `$state` rows, which is the simplest. For a busy feed, keep the rows as plain objects and hand the grid a batch of patches once a frame.

This board takes 40 ticks a second through a tick stream into `api.patchRows`. It is sorted by change and stays sorted as prices move. Rows hold still while the pointer is over them.

Example: A live board (realtime.svelte)

```svelte
<script lang="ts">
  import { Grid, type ColumnDef, type GridApi, type SortState } from 'bo-grid';
  import { createTickStream } from 'bo-grid/realtime';
  import { quotes } from './data';
  import { simulateFeed, type Tick } from './feed';

  // Plain rows ($state.raw): the grid writes each tick into them in place.
  const rows = $state.raw(quotes());
  const columns: ColumnDef[] = [
    { type: 'text', key: 'symbol', header: 'Symbol', sub: 'name', width: 150 },
    { type: 'price', key: 'price', header: 'Price', flash: 'auto', showChange: true, width: 130 },
    { type: 'percent', key: 'change', header: 'Chg %', flash: 'auto' },
    { type: 'volume', key: 'volume', header: 'Volume', updateMs: 500 },
    { type: 'text', key: 'sector', header: 'Sector', flex: 1 },
  ];
  let sort = $state<SortState[]>([{ key: 'change', dir: 'desc' }]);

  let api: GridApi | undefined;
  let applied = $state(0);
  // Coalesces ticks per row and hands the grid one batch a frame.
  const stream = createTickStream<number, Tick>({
    apply: (batch) => {
      api?.patchRows(batch);
      applied = stream.applied;
    },
  });

  $effect(() => {
    stream.start();
    const stop = simulateFeed(rows, (id, tick) => stream.push(id, tick), 40);
    return () => {
      stop();
      stream.stop();
    };
  });
</script>

<p class="note">{applied.toLocaleString()} ticks applied · sorted by Chg %, kept in order as prices move</p>
<Grid {rows} {columns} height={300} {sort} onSortChange={(s) => (sort = s)} liveSort={800} onReady={(a) => (api = a)} />

<style>
  .note {
    margin: 0 0 8px;
    font-size: 12px;
    opacity: 0.7;
  }
</style>
```

data.ts:

```ts
// Small, fixed data for the docs' examples: every example reads the same
// symbols, so a reader compares features, not datasets.
// A type, not an interface: it then fits GridRow's index signature.
export type Quote = {
  id: number;
  symbol: string;
  name: string;
  sector: string;
  price: number;
  change: number;
  volume: number;
};

const BASE: Omit<Quote, 'id'>[] = [
  { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 },
  { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 },
  { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 },
  { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 },
  { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 },
  { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 },
  { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 },
  { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 },
  { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 },
  { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 },
  { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 },
  { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 },
];

/** A fresh copy of the quotes, so one example's edits never reach another. */
export function quotes(): Quote[] {
  return BASE.map((q, i) => ({ id: i + 1, ...q }));
}
```

feed.ts:

```ts
// A simulated market feed for the docs' examples: a random walk on each
// quote's price. In an app, this is your socket's message handler.
import type { Quote } from './data';

export type Tick = { price: number; change: number; volume: number };

/** Calls `onTick` about `perSecond` times a second (a number, or a function
    read on every beat, so the rate can change while it runs), for a random
    row each time. Returns a function that stops the feed. */
export function simulateFeed(rows: readonly Quote[], onTick: (id: number, tick: Tick) => void, perSecond: number | (() => number) = 20): () => void {
  const rate = typeof perSecond === 'function' ? perSecond : () => perSecond;
  const state = rows.map((r) => ({ id: r.id, ref: r.price / (1 + r.change / 100), price: r.price, volume: r.volume }));
  const every = 50;
  let due = 0;
  const timer = setInterval(() => {
    for (due += (rate() * every) / 1000; due >= 1; due--) {
      const s = state[Math.floor(Math.random() * state.length)];
      s.price = Math.max(0.01, Math.round(s.price * (1 + (Math.random() - 0.5) * 0.006) * 100) / 100);
      s.volume += Math.ceil(Math.random() * 40) * 100;
      onTick(s.id, { price: s.price, change: Math.round(((s.price - s.ref) / s.ref) * 10_000) / 100, volume: s.volume });
    }
  }, every);
  return () => clearInterval(timer);
}
```

## Two ways to update rows

**`$state` rows.** Make the rows deep state and assign the new value. The grid sees the write and repaints that cell. A column with `flash: 'auto'` flashes it green or red (see [Cell flashing](https://bonguynvan.github.io/bo-grid/docs/flashing/)):

```svelte
<script lang="ts">
  import { Grid, type ColumnDef } from 'bo-grid';

  let rows = $state(initialQuotes);
  const columns: ColumnDef[] = [
    { type: 'text', key: 'symbol', header: 'Symbol' },
    { type: 'price', key: 'price', header: 'Price', flash: 'auto' },
  ];
  socket.onmessage = (e) => {
    const q = JSON.parse(e.data);
    const row = rows.find((r) => r.symbol === q.symbol);
    if (row) row.price = q.price; // the cell repaints and flashes
  };
</script>

<Grid {rows} {columns} height={480} />
```

This works well for a few hundred writes a second. Every field write goes through a reactive proxy, paid for every row the feed touches, on screen or not.

**Plain rows and `api.patchRows`.** For a full price board (hundreds to thousands of symbols, several fields a tick), give the grid plain objects and let it write the ticks. A [tick stream](https://bonguynvan.github.io/bo-grid/docs/tick-stream/) coalesces the messages per key and hands the grid one batch a frame:

```svelte
<script lang="ts">
  import { Grid, type GridApi } from 'bo-grid';
  import { createTickStream } from 'bo-grid/realtime';

  let rows = $state.raw(initialQuotes); // plain objects: no deep proxy
  let api: GridApi | undefined;
  const stream = createTickStream<string, Partial<Quote>>({
    apply: (batch) => api?.patchRows(batch), // writes in place, repaints rendered cells only
  });
  stream.start();
  socket.onmessage = (e) => {
    const q = JSON.parse(e.data);
    stream.push(q.symbol, q);
  };
</script>

<Grid {rows} {columns} getRowId={(r) => String(r.symbol)} onReady={(a) => (api = a)} height={480} />
```

A patch is `[rowId, fields]`, where the id is the grid's `getRowId` (`row.id` by default). `patchRows` returns how many rows changed. Past about 10,000 messages a second, read the socket in a worker instead: see [Worker feeds](https://bonguynvan.github.io/bo-grid/docs/worker-feed/).

## What repaints

`patchRows` writes into the rows and repaints only the rendered cells whose field changed. A tick that moves the price and the volume repaints those two cells, not the row. Rows off screen pay nothing beyond the write, and show the current values when they scroll in. Footer totals, group subtotals and selection sums stay live.

Some cells can read other fields of the row, so they repaint whenever the row changes:

- renderers: `render`, `component` and the `cell` snippet;
- computed columns (`value`) and `sub` fields;
- any `format`, `cellClass` or `tooltip` function that declares the row parameter.

Write `(value, row) => …` only when the function reads the row, and `(value) => …` otherwise, so the cell skips ticks that don't concern it.

`component` cells and the `cell` snippet update in place. On each patch they receive a fresh view of the row (same fields, new identity), so their markup and `$derived` values re-run while the component, its DOM and its local state stay. Read `row` reactively, not once at mount.

## Keeping the sort current

Values that change in place do not re-sort or re-filter the view on every tick. For a board whose order must follow the feed (top movers, a watchlist sorted by change), set `liveSort`:

```svelte
<Grid {rows} {columns} sort={[{ key: 'change', dir: 'desc' }]} liveSort={500} onReady={(a) => (api = a)} height={480} />
```

When a patch changes a field the grid sorts or filters by, the rows it changed are merged back into place at most every `liveSort` ms (`true` means 500). Only those rows are sorted and merged into the rest: on 100,000 rows with 500 changed, that takes about 5 ms against about 21 ms for a full re-sort. A row that changes other fields queues nothing.

- Rows that stop matching a filter leave; rows that start matching join; a row whose group field changed moves to its new group.
- The focused cell stays on its row as the row moves.
- Nothing moves while a cell is being edited, or while the pointer is over the rows (`pauseOnHover`, on by default, for mouse and pen). Leaving the rows, the window losing focus or the tab hiding ends the pause.

`liveSort` follows `patchRows`. Without it, or for values written into rows any other way, call `api.refresh()` to re-filter and re-sort the whole view against the current values. A row that keeps its position keeps its rendered cells.

## Stale and new rows

- **`staleAfter`** (ms) dims a row when nothing new has come for it in that long: a quote whose feed went quiet. The row comes back the moment it ticks. News is a `patchRows` that changed the row, or a new object for it in `rows`; a patch that re-sends the same values is not news. Style it with `--bo-stale-opacity` (default 0.5).
- **`flashNewRows`** flashes a row once when it arrives in `rows`, such as a new order or a new fill (`true` means 600 ms; or a number of ms). A row arrives when its id is new. A copy of a row already there does not flash, nor do the rows an empty grid receives (a load), nor a wholesale replacement.

```svelte
<Grid {rows} {columns} liveSort staleAfter={5000} flashNewRows onReady={(a) => (api = a)} height={480} />
```

## Busy screens

The cost of a frame grows with the rows on screen, roughly linearly: on the Price board, 80 rows cost about 2.5 times what 30 do. Three settings keep a busy board inside its frame budget:

- **The stream's `cap`** bounds the patches applied in one frame (400 by default). Ticks beyond it wait for the next frame instead of stretching this one.
- **`flashMotion="hold"`** replaces the fading flash with a held tint, about 3 to 4 times less main-thread work under a busy feed. See [Cell flashing](https://bonguynvan.github.io/bo-grid/docs/flashing/#busy-boards).
- **`updateMs`** on a column repaints it at most that often. Every tick is still written into the row at once, so sort, filters, totals and the other columns see it; the cell shows the latest value on its next beat. Use it for values read at a glance that change on every trade (total volume, foreign buy and sell), so they stop repainting every frame while the prices stay instant:

```ts
{ type: 'number', key: 'tv', header: 'Total vol', updateMs: 250 }
```

The first change after a quiet spell shows at once, and a flash plays on the repaint, against the value shown before.

## Measured

On the Price board example (production build, 1,000 symbols, 24 columns, every changed cell flashing, about 30 rows on screen), a frame of a 30,000 events/s feed costs about 4.5 ms (p50) with `patchRows`, against about 5.3 ms through `$state` rows. The writes themselves are about 2.7 times cheaper (0.6 against 1.6 ms), and the gap grows with the number of symbols and fields a tick. The method and the full tables are in [BENCHMARKS.md](https://github.com/bonguynvan/bo-grid/blob/main/BENCHMARKS.md#browser-a-busy-price-board); the board's Benchmark button measures your machine.

The full board, with three bid and ask levels, foreign flow and price-limit colours:

Live example: https://bonguynvan.github.io/bo-grid/examples/priceboard/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/)

## Reference

- `liveSort`: `boolean | number` (default `false`) — Keep the sort and filters current while `api.patchRows` changes values in place. When a patch changes a field the grid sorts or filters by, the rows it changed are merged back into place at most every `liveSort` ms (`true`: 500), instead of the whole view being re-sorted. The selection stays on the same rows; nothing moves while a cell is being edited. Off, rows hold their place until `api.refresh()`. It follows `patchRows`; values written into rows any other way still need `api.refresh()`.

  ```svelte
  <Grid {rows} {columns} height={480} liveSort={250} />
  ```

- `pauseOnHover`: `boolean` (default `true`) — While the pointer is over the rows, `liveSort` waits, so a row does not move just as it is clicked; the rows re-sort once the pointer leaves (or the window loses focus, or the tab is hidden). Mouse and pen only. Set false for a screen nobody points at.
- `staleAfter`: `number` (default `0`) — Dim a row when nothing new has come for it for this many ms: a quote whose feed went quiet. News is a patch (`api.patchRows`) that changed the row, or a new object for it in `rows` (the row arrived, or a copy replaced it); a patch that re-sends the values the row already has is not. The row comes back the moment it ticks. Rows already there count from mount, or from when `staleAfter` is turned on. Dimmed with `opacity: var(--bo-stale-opacity, 0.5)`. `0` turns it off.
- `flashNewRows`: `boolean | number` (default `false`) — Flash a row once when it arrives in `rows` (a new order, a new fill): every cell, in the neutral flash colour, for this many ms (`true`: 600). A row arrives when its id is new; a copy of a row already there does not flash, nor do the rows an empty grid gets (a load or a reload), nor a wholesale replacement (more than 64 rows, and more than an eighth of them, at once). Rows on screen only, as with every flash.

  ```svelte
  <Grid {rows} {columns} height={480} flashNewRows={900} />
  ```

- `getRowId`: `(row: GridRow) => string | number` (default `(row) => row.id`) — Identity key for rows: selection, focus, pins and flashes follow it. Override for string/UUID/composite keys.

  ```svelte
  <Grid {rows} {columns} height={480} getRowId={(row) => `${row.account}:${row.sym}`} />
  ```

- `onReady`: `(api: GridApi) => void` — Called once, when the grid mounts, with a handle for imperative actions: `scrollToRow`, `focusCell`, `patchRows`, `getSelectedRows`, `autosizeColumns`, `exportCSV`, `getState` / `applyState` and more.

  ```svelte
  <Grid {rows} {columns} height={480} onReady={(a) => (api = a)} />
  ```


- `updateMs`: `number` (default `undefined`) — Repaint this column at most every this many ms under `api.patchRows`. Each tick is still written into the row at once (sort, filters, totals and other columns see it), but the cell shows the latest value on its next beat: a value that changes on every tick and is read at a glance (total volume, foreign buy and sell) stops repainting every frame. The first change after a quiet spell shows at once. A flash or `showChange` plays on the repaint, against the value shown before. For the field the column shows (`key`), not a `value` column; when several columns show a field, it waits only if they all set this. Unset, the cell repaints on every tick.

- `patchRows(patches: Iterable<readonly [string | number, Record<string, unknown>]>): number` — Realtime fast path: write `[rowId, fields]` patches into the rows in place and repaint only the rendered rows they change. Rows may be plain objects (no $state proxy) — far cheaper per write in a busy feed. Feed it a tick stream: `createTickStream({ apply: (b) => api.patchRows(b) })`. Sort and filter catch up on the next view change, as for any live data (or at once, with `liveSort`). A patch's id is the row's `getRowId` value (`row.id` unless set). Returns how many rows changed.

  ```ts
  api.patchRows([
    [1, { last: 68.6, vol: 1_204_300 }],
    [2, { last: 121.1 }],
  ]);
  ```

- `refresh(): void` — Re-run filter and sort against the rows' current values. Values that change in place (a feed) never re-sort the view by themselves — that would mean re-sorting on every tick — so call this when the order should catch up, e.g. every second on a "top movers" board (or use `liveSort`).

  ```ts
  const timer = setInterval(() => api.refresh(), 1000);
  ```

---
Page: https://bonguynvan.github.io/bo-grid/docs/realtime/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt
