Data Pushing or Pulling
The LyteNyte Grid's server data source enables programmatic data exchange with the server, giving developers full control over how data is loaded into the grid. It also supports hybrid patterns where row data originates from both the client and the server.
Pulling Data
LyteNyte Grid provides the pushRequests
method on the server data source to request ("pull") data
from the server. When invoked, the grid processes the requests as new, ignoring existing request
tracking. The grid also manages row data conflict resolution and cancels pending requests
if the grid resets before a response is received.
In the example below, clicking the "Request Data" button triggers the grid to tick, a manual refresh similar to those in many UIs that let users poll for updates.
Data Pulling
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import { useId } from "react";import { Server } from "./server";import type { DataEntry } from "./data";import { GroupCell, HeaderCell, NumberCell } from "./components";import type { Column } from "@1771technologies/lytenyte-pro/types";const columns: Column<DataEntry>[] = [{id: "bid",name: "Bid",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "ask",name: "Ask",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "spread",name: "Spread",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "volatility",name: "Volatility",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "latency",name: "Latency",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "symbol",name: "Symbol",hide: true,groupVisibility: "always",type: "number",},];export default function DataPulling() {const ds = useServerDataSource<DataEntry>({dataFetcher: (params) => {return Server(params.requests, params.model.groups, params.model.aggregations);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns: columns,rowGroupModel: ["symbol"],rowGroupColumn: {width: 170,cellRenderer: GroupCell,},columnBase: {headerRenderer: HeaderCell,},aggModel: {time: { fn: "first" },volume: { fn: "group" },bid: { fn: "avg" },ask: { fn: "avg" },spread: { fn: "avg" },volatility: { fn: "first" },latency: { fn: "first" },pnl: { fn: "first" },symbol: { fn: "first" },},});const view = grid.view.useValue();return (<><div className="border-ln-gray-30 flex border-b px-2 py-2"><buttonclassName="border-ln-primary-30 hover:bg-ln-primary-70 bg-ln-primary-50 text-ln-gray-02 cursor-pointer rounded border px-3 py-0.5 text-sm font-semibold"onClick={() => {ds.pushRequests(ds.requestsForView.get());}}>Request Data</button></div><div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm capitalize"/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return <Grid.Cell key={c.id} cell={c} />;})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></>);}
import "./component.css";import type {CellRendererParams,HeaderCellRendererFn,} from "@1771technologies/lytenyte-pro/types";import { ArrowDownIcon, ArrowUpIcon } from "@radix-ui/react-icons";import { usePrevious } from "@uidotdev/usehooks";import clsx from "clsx";import { useEffect, useRef } from "react";import type { DataEntry } from "./data";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";const formatter = new Intl.NumberFormat("en-US", {minimumFractionDigits: 2,maximumFractionDigits: 2,});export function NumberCell({ grid, column, row }: CellRendererParams<DataEntry>) {const field = grid.api.columnField(column, row) as number;const prev = usePrevious(field);const value = typeof field === "number" ? formatter.format(field) : "-";const diff = field - (prev ?? field);const ref = useRef<HTMLDivElement>(null);useEffect(() => {if (!ref.current) return;ref.current.style.animation = "none";void ref.current.offsetWidth;ref.current.style.animation = "fadeOut 3s ease-out forwards";}, [diff]);return (<divclassName={clsx("flex h-full items-center justify-end gap-2 px-2 font-mono tabular-nums tracking-tighter",)}>{diff !== 0 && (<divref={ref}className={clsx("flex items-center rounded px-1 text-[10px]",diff < 0 && "bg-red-800/20 text-red-500",diff > 0 && "bg-green-500/20 text-green-500",)}>{diff < 0 && <ArrowDownIcon width={12} height={12} />}{diff > 0 && <ArrowUpIcon width={12} height={12} />}<span>{diff.toFixed(2)}</span></div>)}{value}</div>);}export function GroupCell({ grid, row }: CellRendererParams<DataEntry>) {if (grid.api.rowIsGroup(row)) {const symbol = row.key;const isExpanded = grid.api.rowGroupIsExpanded(row);return (<div className="flex h-full w-full items-center gap-2 overflow-hidden text-nowrap px-3">{row.loadingGroup && (<div className="w-5"><LoadingSpinner /></div>)}{!row.loadingGroup && (<buttonclassName="hover:bg-ln-gray-10 w-5 cursor-pointer rounded transition-colors"onClick={() => {grid.api.rowGroupToggle(row);}}><span className="sr-only">Toggle the row group</span>{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}</button>)}<div className="flex h-[32px] min-h-[32px] w-[32px] min-w-[32px] items-center justify-center overflow-hidden rounded-full"><imgsrc={`/symbols/${symbol}.png`}alt=""className="h-[26px] min-h-[26px] w-[26px] min-w-[26] rounded-full bg-black p-1"/></div><div className="symbol-cell flex min-w-[60px] items-center justify-center rounded-2xl bg-teal-600/20 px-1 py-0.5 text-xs">{symbol}</div></div>);}if (!grid.api.rowIsLeaf(row)) return null;const symbol = row.data?.["symbol"] as string;return (<div className="flex h-full w-full items-center justify-end gap-3 overflow-hidden text-nowrap px-3"><div className="symbol-cell flex min-w-[60px] items-center justify-center rounded-2xl px-1 py-0.5 text-xs opacity-50">{symbol}</div></div>);}export const HeaderCell: HeaderCellRendererFn<DataEntry> = ({ grid, column }) => {const aggs = grid.state.aggModel.useValue();const agg = aggs[column.id];const aggFn = column.id === "spread" ? "diff" : agg?.fn;const aggName = typeof aggFn === "string" ? aggFn : "Fn(x)";return (<divclassName={clsx("flex h-full w-full items-center gap-2 text-sm text-[var(--lng1771-gray-80)]",column.type === "number" && "flex-row-reverse",)}><div>{column.name ?? column.id}</div>{aggFn && aggFn !== "group" && (<span className="focus-visible:ring-ln-primary-50 rounded px-1 py-1 text-xs text-[var(--lng1771-primary-50)] focus:outline-none focus-visible:ring-1">({aggName as string})</span>)}</div>);};const LoadingSpinner = () => {return (<div className="flex min-h-screen items-center justify-center"><svgclassName="h-4 w-4 animate-spin text-blue-500"xmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"><circleclassName="opacity-25"cx="12"cy="12"r="8"stroke="currentColor"strokeWidth="4"></circle><pathclassName="opacity-75"fill="currentColor"d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path></svg></div>);};
import type {AggModelFn,DataRequest,DataResponse,RowGroupModelItem,} from "@1771technologies/lytenyte-pro/types";import { data, nextData, type DataEntry } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 500));export async function Server(reqs: DataRequest[],groupModel: RowGroupModelItem<DataEntry>[],aggModel: { [columnId: string]: { fn: AggModelFn<DataEntry> } },) {// Simulate latency and server work.await sleep();// Tick the data so it changes in the UInextData();return reqs.map((c) => {// Return flat items if there are no row groupsif (!groupModel.length) {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;}const groupLevel = c.path.length;const groupKeys = groupModel.slice(0, groupLevel + 1);const filteredForGrouping = data.filter((row) => {return c.path.every((v, i) => {const groupKey = groupModel[i];return `${row[groupKey as keyof DataEntry]}` === v;});});// This is the leaf level of the groupingif (groupLevel === groupModel.length) {return {kind: "center",asOfTime: Date.now(),start: c.start,end: c.end,path: c.path,data: filteredForGrouping.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),size: filteredForGrouping.length,} satisfies DataResponse;}const groupedData = Object.groupBy(filteredForGrouping, (r) => {const groupPath = groupKeys.map((g) => {if (typeof g !== "string")throw new Error("Non-string groups are not supported by this dummy implementation");return r[g as keyof DataEntry];});return groupPath.join(" / ");});const rows = Object.entries(groupedData);return {kind: "center",asOfTime: Date.now(),data: rows.slice(c.start, c.end).map((x) => {const childRows = x[1]!;const nextGroup = groupLevel + 1;let childCnt: number;if (nextGroup === groupModel.length) childCnt = childRows.length;else {childCnt = Object.keys(Object.groupBy(childRows, (x) => {const groupKey = groupModel[nextGroup];return `${x[groupKey as keyof DataEntry]}`;}),).length;}const aggData = Object.fromEntries(Object.entries(aggModel).map(([column, m]) => {if (typeof m.fn !== "string")throw new Error("Non-string aggregations are not supported by this dummy implementation",);const id = column as keyof DataEntry;if (m.fn === "first") return [column, childRows[0][id]];if (m.fn === "last") return [column, childRows.at(-1)![id]];if (m.fn === "avg")return [column,childRows.reduce((acc, x) => acc + (x[id] as number), 0) / childRows.length,];if (m.fn === "sum")return [column, childRows.reduce((acc, x) => acc + (x[id] as number), 0)];if (m.fn === "min")return [column, Math.min(...childRows.map((x) => x[id] as number))];if (m.fn === "max")return [column, Math.max(...childRows.map((x) => x[id] as number))];}).filter(Boolean) as [string, number | string][],);return {kind: "branch",childCount: childCnt,data: aggData,id: x[0],key: x[0].split(" / ").at(-1)!,};}),path: c.path,start: c.start,end: c.end,size: rows.length,} satisfies DataResponse;});}
The code for pulling data from the server is simple and runs in
the "Request Data" button's onClick
callback:
<buttononClick={() => {ds.pushRequests(ds.requestsForView.get());}}>Request Data</button>
We use the requestsForView
atom to fetch requests for the current view, though any
request set can be used. If you're unfamiliar with the request interface,
see the Data Interface guide for more details.
Pushing Data
You can push data into LyteNyte Grid using the pushResponses
method on the
server data source. This method accepts complete data responses and sends
them to the data source. It can also be used to generate data ticks on the client
and push the result into the data source as though it were the server that sent the rows.
This is shown in the example below.
Data Pushing
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import { useId } from "react";import { getResponses, Server } from "./server";import type { DataEntry } from "./data";import { GroupCell, HeaderCell, NumberCell } from "./components";import type { Column } from "@1771technologies/lytenyte-pro/types";const columns: Column<DataEntry>[] = [{id: "bid",name: "Bid",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "ask",name: "Ask",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "spread",name: "Spread",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "volatility",name: "Volatility",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "latency",name: "Latency",type: "number",groupVisibility: "always",cellRenderer: NumberCell,width: 120,widthFlex: 1,},{id: "symbol",name: "Symbol",hide: true,groupVisibility: "always",type: "number",},];export default function DataPushing() {const ds = useServerDataSource<DataEntry>({dataFetcher: (params) => {return Server(params.requests, params.model.groups, params.model.aggregations);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns: columns,rowGroupModel: ["symbol"],rowGroupColumn: {width: 170,cellRenderer: GroupCell,},columnBase: {headerRenderer: HeaderCell,},aggModel: {time: { fn: "first" },volume: { fn: "group" },bid: { fn: "avg" },ask: { fn: "avg" },spread: { fn: "avg" },volatility: { fn: "first" },latency: { fn: "first" },pnl: { fn: "first" },symbol: { fn: "first" },},});const view = grid.view.useValue();return (<><div className="border-ln-gray-30 flex border-b px-2 py-2"><buttonclassName="border-ln-primary-30 hover:bg-ln-primary-70 bg-ln-primary-50 text-ln-gray-02 cursor-pointer rounded border px-3 py-0.5 text-sm font-semibold"onClick={() => {const res = getResponses(ds.requestsForView.get(),grid.state.rowGroupModel.get(),grid.state.aggModel.get(),);ds.pushResponses(res);}}>Push Data</button></div><div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm capitalize"/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return <Grid.Cell key={c.id} cell={c} />;})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></>);}
import "./component.css";import type {CellRendererParams,HeaderCellRendererFn,} from "@1771technologies/lytenyte-pro/types";import { ArrowDownIcon, ArrowUpIcon } from "@radix-ui/react-icons";import { usePrevious } from "@uidotdev/usehooks";import clsx from "clsx";import { useEffect, useRef } from "react";import type { DataEntry } from "./data";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";const formatter = new Intl.NumberFormat("en-US", {minimumFractionDigits: 2,maximumFractionDigits: 2,});export function NumberCell({ grid, column, row }: CellRendererParams<DataEntry>) {const field = grid.api.columnField(column, row) as number;const prev = usePrevious(field);const value = typeof field === "number" ? formatter.format(field) : "-";const diff = field - (prev ?? field);const ref = useRef<HTMLDivElement>(null);useEffect(() => {if (!ref.current) return;ref.current.style.animation = "none";void ref.current.offsetWidth;ref.current.style.animation = "fadeOut 3s ease-out forwards";}, [diff]);return (<divclassName={clsx("flex h-full items-center justify-end gap-2 px-2 font-mono tabular-nums tracking-tighter",)}>{diff !== 0 && (<divref={ref}className={clsx("flex items-center rounded px-1 text-[10px]",diff < 0 && "bg-red-800/20 text-red-500",diff > 0 && "bg-green-500/20 text-green-500",)}>{diff < 0 && <ArrowDownIcon width={12} height={12} />}{diff > 0 && <ArrowUpIcon width={12} height={12} />}<span>{diff.toFixed(2)}</span></div>)}{value}</div>);}export function GroupCell({ grid, row }: CellRendererParams<DataEntry>) {if (grid.api.rowIsGroup(row)) {const symbol = row.key;const isExpanded = grid.api.rowGroupIsExpanded(row);return (<div className="flex h-full w-full items-center gap-2 overflow-hidden text-nowrap px-3">{row.loadingGroup && (<div className="w-5"><LoadingSpinner /></div>)}{!row.loadingGroup && (<buttonclassName="hover:bg-ln-gray-10 w-5 cursor-pointer rounded transition-colors"onClick={() => {grid.api.rowGroupToggle(row);}}><span className="sr-only">Toggle the row group</span>{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}</button>)}<div className="flex h-[32px] min-h-[32px] w-[32px] min-w-[32px] items-center justify-center overflow-hidden rounded-full"><imgsrc={`/symbols/${symbol}.png`}alt=""className="h-[26px] min-h-[26px] w-[26px] min-w-[26] rounded-full bg-black p-1"/></div><div className="symbol-cell flex min-w-[60px] items-center justify-center rounded-2xl bg-teal-600/20 px-1 py-0.5 text-xs">{symbol}</div></div>);}if (!grid.api.rowIsLeaf(row)) return null;const symbol = row.data?.["symbol"] as string;return (<div className="flex h-full w-full items-center justify-end gap-3 overflow-hidden text-nowrap px-3"><div className="symbol-cell flex min-w-[60px] items-center justify-center rounded-2xl px-1 py-0.5 text-xs opacity-50">{symbol}</div></div>);}export const HeaderCell: HeaderCellRendererFn<DataEntry> = ({ grid, column }) => {const aggs = grid.state.aggModel.useValue();const agg = aggs[column.id];const aggFn = column.id === "spread" ? "diff" : agg?.fn;const aggName = typeof aggFn === "string" ? aggFn : "Fn(x)";return (<divclassName={clsx("flex h-full w-full items-center gap-2 text-sm text-[var(--lng1771-gray-80)]",column.type === "number" && "flex-row-reverse",)}><div>{column.name ?? column.id}</div>{aggFn && aggFn !== "group" && (<span className="focus-visible:ring-ln-primary-50 rounded px-1 py-1 text-xs text-[var(--lng1771-primary-50)] focus:outline-none focus-visible:ring-1">({aggName as string})</span>)}</div>);};const LoadingSpinner = () => {return (<div className="flex min-h-screen items-center justify-center"><svgclassName="h-4 w-4 animate-spin text-blue-500"xmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"><circleclassName="opacity-25"cx="12"cy="12"r="8"stroke="currentColor"strokeWidth="4"></circle><pathclassName="opacity-75"fill="currentColor"d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path></svg></div>);};
import type {AggModelFn,DataRequest,DataResponse,RowGroupModelItem,} from "@1771technologies/lytenyte-pro/types";import { data, nextData, type DataEntry } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 50));export async function Server(reqs: DataRequest[],groupModel: RowGroupModelItem<DataEntry>[],aggModel: { [columnId: string]: { fn: AggModelFn<DataEntry> } },) {// Simulate latency and server work.await sleep();return getResponses(reqs, groupModel, aggModel);}export function getResponses(reqs: DataRequest[],groupModel: RowGroupModelItem<DataEntry>[],aggModel: { [columnId: string]: { fn: AggModelFn<DataEntry> } },) {// Tick the data so it changes in the UInextData();return reqs.map((c) => {// Return flat items if there are no row groupsif (!groupModel.length) {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;}const groupLevel = c.path.length;const groupKeys = groupModel.slice(0, groupLevel + 1);const filteredForGrouping = data.filter((row) => {return c.path.every((v, i) => {const groupKey = groupModel[i];return `${row[groupKey as keyof DataEntry]}` === v;});});// This is the leaf level of the groupingif (groupLevel === groupModel.length) {return {kind: "center",asOfTime: Date.now(),start: c.start,end: c.end,path: c.path,data: filteredForGrouping.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),size: filteredForGrouping.length,} satisfies DataResponse;}const groupedData = Object.groupBy(filteredForGrouping, (r) => {const groupPath = groupKeys.map((g) => {if (typeof g !== "string")throw new Error("Non-string groups are not supported by this dummy implementation");return r[g as keyof DataEntry];});return groupPath.join(" / ");});const rows = Object.entries(groupedData);return {kind: "center",asOfTime: Date.now(),data: rows.slice(c.start, c.end).map((x) => {const childRows = x[1]!;const nextGroup = groupLevel + 1;let childCnt: number;if (nextGroup === groupModel.length) childCnt = childRows.length;else {childCnt = Object.keys(Object.groupBy(childRows, (x) => {const groupKey = groupModel[nextGroup];return `${x[groupKey as keyof DataEntry]}`;}),).length;}const aggData = Object.fromEntries(Object.entries(aggModel).map(([column, m]) => {if (typeof m.fn !== "string")throw new Error("Non-string aggregations are not supported by this dummy implementation",);const id = column as keyof DataEntry;if (m.fn === "first") return [column, childRows[0][id]];if (m.fn === "last") return [column, childRows.at(-1)![id]];if (m.fn === "avg")return [column,childRows.reduce((acc, x) => acc + (x[id] as number), 0) / childRows.length,];if (m.fn === "sum")return [column, childRows.reduce((acc, x) => acc + (x[id] as number), 0)];if (m.fn === "min")return [column, Math.min(...childRows.map((x) => x[id] as number))];if (m.fn === "max")return [column, Math.max(...childRows.map((x) => x[id] as number))];}).filter(Boolean) as [string, number | string][],);return {kind: "branch",childCount: childCnt,data: aggData,id: x[0],key: x[0].split(" / ").at(-1)!,};}),path: c.path,start: c.start,end: c.end,size: rows.length,} satisfies DataResponse;});}
This example is intentionally simple to highlight the core functionality. Like
the data-pulling example, the main logic runs in the "Push Data" button's onClick
handler. However, instead of calling pushRequests
, it calls pushResponses
and provides complete responses.
<buttononClick={() => {const res = getResponses(ds.requestsForView.get(),grid.state.rowGroupModel.get(),grid.state.aggModel.get(),);ds.pushResponses(res);}}>Push Data</button>
Pushing responses requires a solid understanding of the LyteNyte Grid server data source's response model and how responses form the data tree. See the Data Interface guide for more information.
Next Steps
- Server Row Sorting: explains server-side sorting and client-side display.
- Server Row Filtering: covers server-side filtering, including in filters (tree set filters) and quick search filters.
- Server Row Grouping and Aggregation: handles grouped data and loads group slices.
- Handling Load Failures: explores all scenarios where data requests may fail.
Optimistic Loading
Predicting the data a user will want to view next is key to building a server data-loading solution that feels instant. This guide explains how to optimistically load rows so users never encounter a loading indicator.
Handling Load Failures
Network requests can fail at any time, that's an inherent part of client-server communication. The LyteNyte Grid server data source provides simple mechanisms to recover from request errors, making it easy to retry failed requests.