Skip to content

Live stock prices ​

A simulated price feed updates the grid up to 50 times per second.

How it works ​

  • Immutable updates: every tick creates a new array in which the changed stock is replaced by a new object. With rowId: (stock) => stock.symbol the grid reuses the existing rows and only the changed cells render again. Sorting is kept, so a sorted column reorders live.
  • Cell flashing: columns with flashOnChange: true highlight changed values — green for increasing and red for decreasing numbers. The colors are the CSS variables --ut-flash-up-color and --ut-flash-down-color.
  • Sparklines: the history column uses a tiny function component which renders an inline SVG.
  • Aggregations: the total row shows the total volume and the average change, group by sector (column menu) to see it per sector.
  • Fixed row height: rowHeight: 34 avoids measuring rows, which is recommended for frequently updated grids.
vue
<template>
  <div class="demo">
    <div class="demo-toolbar">
      <button @click="running = !running">{{ running ? "⏸ Pause" : "▶ Resume" }}</button>
      <label>
        Updates / second
        <select v-model.number="rate">
          <option :value="2">2</option>
          <option :value="10">10</option>
          <option :value="50">50</option>
        </select>
      </label>
      <span>{{ updates.toLocaleString() }} updates</span>
    </div>
    <Datagrid :columns="columns" :rows="stocks" :options="options" />
  </div>
</template>

<script setup lang="ts">
import { h, onBeforeUnmount, ref, shallowRef, watch } from "vue";
import { Datagrid, formatters, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";

type Stock = {
  symbol: string;
  name: string;
  sector: string;
  price: number;
  previous: number;
  change: number;
  volume: number;
  history: Array<number>;
};

const companies: Array<[string, string, string]> = [
  ["AAPL", "Apple", "Technology"], ["MSFT", "Microsoft", "Technology"], ["NVDA", "Nvidia", "Technology"],
  ["AMZN", "Amazon", "Consumer"], ["TSLA", "Tesla", "Consumer"], ["NKE", "Nike", "Consumer"],
  ["JPM", "JPMorgan", "Finance"], ["GS", "Goldman Sachs", "Finance"], ["V", "Visa", "Finance"],
  ["XOM", "Exxon", "Energy"], ["CVX", "Chevron", "Energy"], ["SHEL", "Shell", "Energy"],
  ["PFE", "Pfizer", "Health"], ["JNJ", "Johnson & Johnson", "Health"], ["MRK", "Merck", "Health"],
  ["SAP", "SAP", "Technology"], ["SIE", "Siemens", "Industrial"], ["BA", "Boeing", "Industrial"],
  ["CAT", "Caterpillar", "Industrial"], ["DHL", "DHL Group", "Industrial"],
];

const stocks = shallowRef<Array<Stock>>(
  companies.map(([symbol, name, sector], i) => {
    const price = 50 + ((i * 37) % 400);
    return { symbol, name, sector, price, previous: price, change: 0, volume: 0, history: Array(30).fill(price) };
  })
);

const running = ref(true);
const rate = ref(10);
const updates = ref(0);

// simulates a price feed: every tick one stock gets a new price
function tick() {
  const index = Math.floor(Math.random() * stocks.value.length);
  const next = stocks.value.map((stock, i) => {
    if (i !== index) return stock;
    const price = Math.max(1, stock.price * (1 + (Math.random() - 0.5) * 0.02));
    return {
      ...stock,
      previous: stock.price,
      price,
      change: ((price - stock.history[0]) / stock.history[0]) * 100,
      volume: stock.volume + Math.round(Math.random() * 10000),
      history: [...stock.history.slice(1), price],
    };
  });
  // a new array with new objects for changed rows, rows are matched by rowId
  stocks.value = next;
  updates.value++;
}

let timer: ReturnType<typeof setInterval> | undefined;
watch(
  [running, rate],
  () => {
    clearInterval(timer);
    if (running.value) timer = setInterval(tick, 1000 / rate.value);
  },
  { immediate: true }
);
onBeforeUnmount(() => clearInterval(timer));

// tiny inline svg sparkline
function sparkline(values: Array<number>) {
  const min = Math.min(...values);
  const max = Math.max(...values);
  const range = max - min || 1;
  const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${20 - ((v - min) / range) * 18 - 1}`).join(" ");
  return `<svg viewBox="0 0 100 20" preserveAspectRatio="none" width="100%" height="20"><polyline fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" points="${points}"/></svg>`;
}

const SparklineCell = (props: { modelValue: Array<number>; item: { data: Stock } }) => {
  const up = props.item.data.change >= 0;
  return h("span", { class: ["sparkline", up ? "up" : "down"], innerHTML: sparkline(props.modelValue) });
};

const columns: Array<ColumnConfig<Stock>> = [
  { field: "symbol", text: "Symbol", width: 90, pinned: "left", cellClass: "symbol" },
  { field: "name", text: "Company", width: 170 },
  { field: "sector", text: "Sector", width: 120, groupable: true, filterType: "set" },
  {
    field: "price",
    text: "Price",
    type: "number",
    width: 110,
    valueFormatter: formatters.currency("USD"),
    // green / red highlight when the price changes
    flashOnChange: true,
  },
  {
    field: "change",
    text: "Change",
    type: "number",
    width: 100,
    aggFunc: "avg",
    valueFormatter: ({ value }) => (value == null ? "" : `${value >= 0 ? "▲" : "▼"} ${Math.abs(value).toFixed(2)} %`),
    cellClass: ({ value }) => (value > 0 ? "positive" : value < 0 ? "negative" : undefined),
    flashOnChange: true,
  },
  { field: "volume", text: "Volume", type: "number", width: 120, aggFunc: "sum", valueFormatter: formatters.number(), flashOnChange: true },
  { field: "history", text: "Trend", width: 160, component: SparklineCell, sortable: false, filterable: false },
];

const options: GridOptions<Stock> = {
  // rows are identified by the symbol, so the grid can update them in place
  rowId: (stock) => stock.symbol,
  rowHeight: 34,
  totalRow: "bottom",
  columnMenu: true,
  defaultColumn: { sortable: true, filterable: true, resizeable: true },
};
</script>

<style>
.symbol {
  font-weight: 700;
  font-family: var(--vp-font-family-mono, monospace);
}
.positive {
  color: #1a7f37;
}
.negative {
  color: #cf222e;
}
.sparkline {
  display: block;
  width: 100%;
}
.sparkline.up {
  color: #1a7f37;
}
.sparkline.down {
  color: #cf222e;
}
</style>
tsx
import { useEffect, useState } from "react";
import {
  Datagrid,
  formatters,
  type ColumnConfig,
  type DatagridProps,
  type GridOptions,
} from "@datagrid/react-ui";

type Stock = {
  symbol: string;
  name: string;
  sector: string;
  price: number;
  previous: number;
  change: number;
  volume: number;
  history: Array<number>;
};

const companies: Array<[string, string, string]> = [
  ["AAPL", "Apple", "Technology"], ["MSFT", "Microsoft", "Technology"], ["NVDA", "Nvidia", "Technology"],
  ["AMZN", "Amazon", "Consumer"], ["TSLA", "Tesla", "Consumer"], ["NKE", "Nike", "Consumer"],
  ["JPM", "JPMorgan", "Finance"], ["GS", "Goldman Sachs", "Finance"], ["V", "Visa", "Finance"],
  ["XOM", "Exxon", "Energy"], ["CVX", "Chevron", "Energy"], ["SHEL", "Shell", "Energy"],
  ["PFE", "Pfizer", "Health"], ["JNJ", "Johnson & Johnson", "Health"], ["MRK", "Merck", "Health"],
  ["SAP", "SAP", "Technology"], ["SIE", "Siemens", "Industrial"], ["BA", "Boeing", "Industrial"],
  ["CAT", "Caterpillar", "Industrial"], ["DHL", "DHL Group", "Industrial"],
];

const initialStocks: Array<Stock> = companies.map(([symbol, name, sector], i) => {
  const price = 50 + ((i * 37) % 400);
  return { symbol, name, sector, price, previous: price, change: 0, volume: 0, history: Array(30).fill(price) };
});

// simulates a price feed: every tick one stock gets a new price
function tick(stocks: Array<Stock>): Array<Stock> {
  const index = Math.floor(Math.random() * stocks.length);
  // a new array with new objects for changed rows, rows are matched by rowId
  return stocks.map((stock, i) => {
    if (i !== index) return stock;
    const price = Math.max(1, stock.price * (1 + (Math.random() - 0.5) * 0.02));
    return {
      ...stock,
      previous: stock.price,
      price,
      change: ((price - stock.history[0]) / stock.history[0]) * 100,
      volume: stock.volume + Math.round(Math.random() * 10000),
      history: [...stock.history.slice(1), price],
    };
  });
}

// tiny inline svg sparkline
function Sparkline({ values, up }: { values: Array<number>; up: boolean }) {
  const min = Math.min(...values);
  const max = Math.max(...values);
  const range = max - min || 1;
  const points = values.map((v, i) => `${(i / (values.length - 1)) * 100},${20 - ((v - min) / range) * 18 - 1}`).join(" ");
  return (
    <span className={`sparkline ${up ? "up" : "down"}`}>
      <svg viewBox="0 0 100 20" preserveAspectRatio="none" width="100%" height="20">
        <polyline fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" points={points} />
      </svg>
    </span>
  );
}

const cellRenderers: DatagridProps<Stock>["cellRenderers"] = {
  history: ({ value, data }) => <Sparkline values={value} up={data.change >= 0} />,
};

const columns: Array<ColumnConfig<Stock>> = [
  { field: "symbol", text: "Symbol", width: 90, pinned: "left", cellClass: "symbol" },
  { field: "name", text: "Company", width: 170 },
  { field: "sector", text: "Sector", width: 120, groupable: true, filterType: "set" },
  {
    field: "price",
    text: "Price",
    type: "number",
    width: 110,
    valueFormatter: formatters.currency("USD"),
    // green / red highlight when the price changes
    flashOnChange: true,
  },
  {
    field: "change",
    text: "Change",
    type: "number",
    width: 100,
    aggFunc: "avg",
    valueFormatter: ({ value }) => (value == null ? "" : `${value >= 0 ? "▲" : "▼"} ${Math.abs(value).toFixed(2)} %`),
    cellClass: ({ value }) => (value > 0 ? "positive" : value < 0 ? "negative" : undefined),
    flashOnChange: true,
  },
  { field: "volume", text: "Volume", type: "number", width: 120, aggFunc: "sum", valueFormatter: formatters.number(), flashOnChange: true },
  // rendered by cellRenderers.history
  { field: "history", text: "Trend", width: 160, sortable: false, filterable: false },
];

const options: GridOptions<Stock> = {
  // rows are identified by the symbol, so the grid can update them in place
  rowId: (stock) => stock.symbol,
  rowHeight: 34,
  totalRow: "bottom",
  columnMenu: true,
  defaultColumn: { sortable: true, filterable: true, resizeable: true },
};

export default function ShowcaseStocks() {
  const [stocks, setStocks] = useState(initialStocks);
  const [running, setRunning] = useState(true);
  const [rate, setRate] = useState(10);
  const [updates, setUpdates] = useState(0);

  // restart the timer when running or rate change, stop it on unmount
  useEffect(() => {
    if (!running) return;
    const timer = setInterval(() => {
      setStocks(tick);
      setUpdates((count) => count + 1);
    }, 1000 / rate);
    return () => clearInterval(timer);
  }, [running, rate]);

  return (
    <div className="demo">
      <div className="demo-toolbar">
        <button onClick={() => setRunning(!running)}>{running ? "⏸ Pause" : "▶ Resume"}</button>
        <label>
          Updates / second{" "}
          <select value={rate} onChange={(e) => setRate(Number(e.target.value))}>
            <option value={2}>2</option>
            <option value={10}>10</option>
            <option value={50}>50</option>
          </select>
        </label>
        <span>{updates.toLocaleString()} updates</span>
      </div>
      <Datagrid columns={columns} rows={stocks} options={options} cellRenderers={cellRenderers} />
      <style>{`
        .symbol {
          font-weight: 700;
          font-family: var(--vp-font-family-mono, monospace);
        }
        .positive {
          color: #1a7f37;
        }
        .negative {
          color: #cf222e;
        }
        .sparkline {
          display: block;
          width: 100%;
        }
        .sparkline.up {
          color: #1a7f37;
        }
        .sparkline.down {
          color: #cf222e;
        }
      `}</style>
    </div>
  );
}

Released under the ISC License.