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.
Optimistically Fetching Data Slices
Optimistic data loading is not a one-size-fits-all strategy. Instead, it's about predicting which data a user is likely to view next and preparing it in advance. This approach is common on webpages, where links to other pages are preloaded to speed up navigation. We can apply the same principle to loading data slices.
Before looking at examples, note that optimistic loading is not a fixed sequence of functions. The best approach depends on your application and how users interact with it. The examples in this guide introduce common use cases and demonstrate how LyteNyte Grid supports optimistic data loading.
The example below demonstrates how to optimistically load slices in a flat data view. This works by tracking the current view and preloading the next slice whenever the view changes. This way, the grid “stays ahead of the scroll.” Note that this does not guarantee users will never see a loading indicator. Scrolling is fast, and if users scroll too quickly, the grid may not have finished fetching rows before they become visible.
Prefetch Next Block
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useEffect, useId } from "react";import { Server } from "./server";import type { MovieData } from "./data";import {GenreRenderer,LinkRenderer,NameCellRenderer,RatingRenderer,ReleasedRenderer,TypeRenderer,} from "./components";const columns: Column<MovieData>[] = [{id: "#",name: "",width: 30,field: "link",widthMin: 30,widthMax: 30,cellRenderer: LinkRenderer,},{ id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },{ id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },{ id: "genre", name: "Genre", cellRenderer: GenreRenderer },{ id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },{ id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },];export default function OptimisticData() {const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,});const view = grid.view.useValue();useEffect(() => {// Watch for view changes to track the current requests that define the view.return ds.requestsForView.watch(() => {// When the view changes, grab the next view value using the atom's get method.// Since this grid is flat, take the last item in the view.const view = ds.requestsForView.get().at(-1);if (!view) return;// Compute the next slice for this view. Returns null if the current view is the last one.const next = ds.requestForNextSlice(view);if (!next || ds.seenRequests.has(next.id)) return;// Mark this request as seen so the grid doesn’t refetch it when scrolled into view.// This step is optional but helps LyteNyte Grid track requested data.ds.seenRequests.add(next.id);// Push the new request into the grid. This triggers LyteNyte Grid to fetch the data.ds.pushRequests([next]);});}, [ds]);return (<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.Cellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type { CellRendererFn } from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { format } from "date-fns";import type { JSX } from "react";import { Rating, ThinRoundedStar } from "@smastrom/react-rating";import "@smastrom/react-rating/style.css";import { Link1Icon } from "@radix-ui/react-icons";function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}export const NameCellRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return <div className="overflow-hidden text-ellipsis">{field}</div>;};export const ReleasedRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const formatted = field ? format(field, "dd MMM yyyy") : "-";return <div>{formatted}</div>;};export const GenreRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const splits = field ? field.split(",") : [];return (<div className="flex h-full w-full items-center gap-1">{splits.map((c) => {return (<divclassName="border-primary-200 text-primary-700 dark:text-primary-500 bg-primary-200/20 rounded border p-1 px-2 text-xs"key={c}>{c}</div>);})}</div>);};const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><path d="M232,216H183.36A103.95,103.95,0,1,0,128,232H232a8,8,0,0,0,0-16ZM40,128a88,88,0,1,1,88,88A88.1,88.1,0,0,1,40,128Zm88-24a24,24,0,1,0-24-24A24,24,0,0,0,128,104Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,128,72Zm24,104a24,24,0,1,0-24,24A24,24,0,0,0,152,176Zm-32,0a8,8,0,1,1,8,8A8,8,0,0,1,120,176Zm56-24a24,24,0,1,0-24-24A24,24,0,0,0,176,152Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,176,120ZM80,104a24,24,0,1,0,24,24A24,24,0,0,0,80,104Zm0,32a8,8,0,1,1,8-8A8,8,0,0,1,80,136Z"></path></svg>);};const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Zm-3.56-110.66-48-32A8,8,0,0,0,104,88v64a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM120,137.05V103l25.58,17Z"></path></svg>);};export const TypeRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const isMovie = field === "Movie";const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;return (<div className="flex h-full w-full items-center gap-2"><span className={isMovie ? "text-primary-500" : "text-accent-500"}><Icon /></span><span>{field}</span></div>);};export const RatingRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const rating = field ? Number.parseFloat(field.split("/")[0]) : null;if (rating == null || Number.isNaN(rating)) return "-";return (<div className="flex h-full w-full items-center"><Ratingstyle={{ maxWidth: 100 }}halfFillMode="svg"value={Math.round(rating / 2)}itemStyles={{activeFillColor: "hsla(173, 78%, 34%, 1)",itemShapes: ThinRoundedStar,inactiveFillColor: "transparent",inactiveBoxBorderColor: "transparent",inactiveBoxColor: "transparent",inactiveStrokeColor: "transparent",}}readOnly/></div>);};export const LinkRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return (<a href={field}><Link1Icon /></a>);};
import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro/types";import { data } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[]) {// Simulate latency and server work.await sleep();return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.uniq_id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}
In the example above, optimistic loading occurs within a single useEffect
, but it uses several parts of the
server data source interface. The full effect is shown and explained here, but check out the
RowDataSourceServer
reference for a complete breakdown
of the methods available on the server data source.
useEffect(() => {// Watch for view changes to track the current requests that define the view.return ds.requestsForView.watch(() => {// When the view changes, grab the next view value using the atom's get method.// Since this grid is flat, take the last item in the view.const view = ds.requestsForView.get().at(-1);if (!view) return;// Compute the next slice for this view. Returns null if the current view is the last one.const next = ds.requestForNextSlice(view);if (!next || ds.seenRequests.has(next.id)) return;// Mark this request as seen so the grid doesn’t refetch it when scrolled into view.// This step is optional but helps LyteNyte Grid track requested data.ds.seenRequests.add(next.id);// Push the new request into the grid. This triggers LyteNyte Grid to fetch the data.ds.pushRequests([next]);});}, [ds]);
Notice how multiple parts of the data source API work together to create an optimistic data-loading pattern. LyteNyte Grid's API for optimistic loading is intentionally low-level, allowing developers to tailor the behavior to their specific use case.
Optimistically Fetching Group Rows
Another way to optimistically fetch rows is when row groups are present. Before the user expands a group, you can fetch its data in advance. A convenient approach is to detect when a row is hovered by the user's mouse cursor and then fetch the data for that row's children. The example below demonstrates this:
Preload Group Rows On Hover
"use client";import "./component.css";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import { Server } from "./server";import type { SalaryData } from "./data";import clsx from "clsx";import { twMerge } from "tailwind-merge";import {AgeCellRenderer,BaseCellRenderer,GroupCellRenderer,HeaderCell,SalaryRenderer,YearsOfExperienceRenderer,} from "./components";import { GroupPills } from "./ui";const columns: Column<SalaryData>[] = [{id: "Gender",width: 120,widthFlex: 1,uiHints: { rowGroupable: true, aggsAllowed: ["first", "last"] },cellRenderer: BaseCellRenderer,},{id: "Education Level",name: "Education",width: 160,widthFlex: 1,uiHints: { rowGroupable: true },hide: true,cellRenderer: BaseCellRenderer,},{id: "Age",type: "number",width: 100,widthFlex: 1,uiHints: { rowGroupable: true, aggsAllowed: ["avg", "first", "last"] },cellRenderer: AgeCellRenderer,},{id: "Years of Experience",name: "YoE",type: "number",width: 100,widthFlex: 1,cellRenderer: YearsOfExperienceRenderer,uiHints: {rowGroupable: true,aggsAllowed: ["avg", "max", "min", "sum"],},},{id: "Salary",type: "number",width: 160,widthFlex: 1,cellRenderer: SalaryRenderer,uiHints: {aggsAllowed: ["avg", "max", "min", "sum"],},},];export default function RowGroupingAggregated() {const ds = useServerDataSource<SalaryData>({dataFetcher: (params) => {return Server(params.requests, params.model.groups, params.model.aggregations);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,aggModel: {Gender: { fn: "first" },Age: { fn: "avg" },Salary: { fn: "avg" },"Years of Experience": { fn: "max" },},columnBase: {headerRenderer: HeaderCell,},rowGroupColumn: {cellRenderer: GroupCellRenderer,widthFlex: 1,},rowGroupModel: ["Education Level"],});const view = grid.view.useValue();return (<><GroupPills grid={grid} /><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={twMerge(clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end",),)}/>);})}</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.Rowrow={row}key={row.id}onMouseEnter={() => {const req = ds.requestForGroup(row.rowIndex);if (!req || ds.seenRequests.has(req.id)) return;// Let's load thisds.seenRequests.add(req.id);ds.pushRequests([req]);}}>{row.cells.map((c) => {return (<Grid.Cellkey={c.id}cell={c}className={twMerge(clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end tabular-nums",),)}/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></>);}
import type {CellRendererParams,HeaderCellRendererFn,} from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import type { CSSProperties } from "react";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";import { AggMenu, tw } from "./ui";function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}const formatter = new Intl.NumberFormat("en-Us", {minimumFractionDigits: 0,maximumFractionDigits: 0,});export function SalaryRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>);}export function YearsOfExperienceRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{formatter.format(field)}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function AgeCellRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{formatter.format(field)}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function BaseCellRenderer({ row, column, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;const field = grid.api.columnField(column, row);return <div className="flex h-full w-full items-center">{(field as string) || "-"}</div>;}export function GroupCellRenderer({ row, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (grid.api.rowIsLeaf(row)) return <div />;const isExpanded = grid.api.rowGroupIsExpanded(row);return (<divstyle={{paddingLeft: row.depth * 16,"--before-offset": `${row.depth * 16 - 5}px`,} as CSSProperties}className={tw("relative flex h-full w-full items-center gap-2 overflow-hidden text-nowrap",row.depth > 0 &&"before:border-ln-gray-30 before:absolute before:left-[var(--before-offset)] before:top-0 before:h-full before:border-r before:border-dashed",)}>{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="w-full overflow-hidden text-ellipsis">{row.key || "(none)"}</div></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>);};export const HeaderCell: HeaderCellRendererFn<SalaryData> = ({ grid, column }) => {const aggModel = grid.state.aggModel.useValue();const hasGroups = grid.state.rowGroupModel.useValue().length > 0;const aggFn = aggModel[column.id]?.fn;return (<divclassName={tw("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 && hasGroups && <AggMenu grid={grid} column={column} />}</div>);};
import type {AggModelFn,DataRequest,DataResponse,RowGroupModelItem,} from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import { data } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[],groupModel: RowGroupModelItem<SalaryData>[],aggModel: { [columnId: string]: { fn: AggModelFn<SalaryData> } },) {// Simulate latency and server work.await sleep();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 SalaryData]}` === 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 SalaryData];});return groupPath.join(" / ");});// Sort the groups to make them nicerconst rows = Object.entries(groupedData).sort((x, y) => {const left = x[0];const right = y[0];const asNumberLeft = Number.parseFloat(left.split("/").at(-1)!.trim());const asNumberRight = Number.parseFloat(right.split("/").at(-1)!.trim());if (Number.isNaN(asNumberLeft) || Number.isNaN(asNumberRight)) {if (!left && !right) return 0;if (!left) return 1;if (!right) return -1;return left.localeCompare(right);}return asNumberLeft - asNumberRight;});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 SalaryData];}),).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 SalaryData;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;});}
import { GridBox } from "@1771technologies/lytenyte-pro";import type { ComponentProps } from "react";import { type PropsWithChildren, type ReactNode } from "react";import type { ClassValue } from "clsx";import clsx from "clsx";import { twMerge } from "tailwind-merge";import type { SalaryData } from "./data";import type { Grid } from "@1771technologies/lytenyte-pro/types";import type { HeaderCellRendererParams } from "@1771technologies/lytenyte-pro/types";import { DragDotsSmallIcon, TickmarkIcon } from "@1771technologies/lytenyte-pro/icons";import { DropdownMenu as D, DropdownMenu } from "radix-ui";export function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}export interface PillManagerRowProps {readonly icon: ReactNode;readonly label: string;readonly className?: string;}export function PillManagerRow({icon,label,children,className,}: PropsWithChildren<PillManagerRowProps>) {return (<divclassName={tw("bg-ln-gray-05 border-ln-gray-20 grid grid-cols-[42px_1fr_64px] border-t md:grid-cols-[151px_1fr_118px]",)}><div className="text-ln-gray-80 flex min-h-[52px] items-center justify-center gap-2 text-sm md:justify-start md:pl-[30px] md:pr-3">{icon}<div className="hidden md:block">{label}</div></div><GridBox.PanelclassName={tw("no-scrollbar flex max-h-[200px] w-full items-center overflow-auto focus:outline-none md:max-h-[unset]","focus-visible:outline-ln-primary-50 focus-visible:outline-offset-[-1px]",className,)}>{children}</GridBox.Panel></div>);}export function GroupPills({ grid }: { grid: Grid<SalaryData> }) {const { rootProps, items } = GridBox.useRowGroupBoxItems({grid,orientation: "horizontal",hideColumnOnGroup: true,includeGroupables: true,placeholder: (el) => el.firstElementChild! as HTMLElement,});return (<GridBox.Root {...rootProps}><PillManagerRowicon={<RowGroupIcon />}label="Row Groups"className="data-[ln-can-drop=true]:bg-ln-primary-10">{items.map((c) => {const isActive = c.active ?? true;return (<GridBox.Itemkey={c.id}item={c}className="horizontal-indicators flex h-[52px] items-center"onKeyDown={(ev) => {if (ev.key === " ")if (isActive) {c.onDelete(ev.currentTarget);} else {grid.state.rowGroupModel.set((prev) => [...prev, c.id]);}}}onClick={(e) => {if (isActive) {c.onDelete(e.currentTarget);} else {grid.state.rowGroupModel.set((prev) => [...prev, c.id]);grid.api.columnUpdate({ [c.id]: { hide: true } });}}}itemClassName={tw("h-full flex items-center px-[6px] focus:outline-none group text-ln-gray-90 ","opacity-60 hover:opacity-80 transition-opacity cursor-pointer",isActive && "opacity-100 hover:opacity-100",)}><div className="bg-ln-pill-group-fill border-ln-pill-group-stroke group-focus-visible:ring-ln-primary-50 flex h-[28px] cursor-pointer items-center text-nowrap rounded border pl-1 group-focus-visible:ring-1">{isActive && <DragDotsSmallIcon className="no-drag" />}<div className={tw("pl-1 pr-3 text-xs", !isActive && "pr-2")}>{c.label}</div></div></GridBox.Item>);})}</PillManagerRow></GridBox.Root>);}function RowGroupIcon() {return (<svg width="14" height="14" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><pathd="M2.5 3.75H17.203"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M2.5 9.56055H9.07178"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M2.5 15.4688H9"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M12 12.8281L14.8284 12.8281L14.8284 9.9997"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/><pathd="M14.8286 15.6572L14.8286 12.8288L17.657 12.8288"stroke="currentcolor"strokeWidth="1.5"strokeLinecap="round"strokeLinejoin="round"/></svg>);}export function AggMenu({column,grid,className,}: HeaderCellRendererParams<any> & { className?: string }) {const base = grid.state.columnBase.useValue();const aggs = grid.state.aggModel.useValue();const agg = aggs[column.id];const aggName = typeof agg?.fn === "string" ? agg?.fn : "Fn(x)";const options = column.uiHints?.aggsAllowed ?? base.uiHints?.aggsAllowed ?? [];return (<D.Root><D.Trigger className={tw(className)} asChild><button className="focus-visible:ring-ln-primary-50 rounded px-1 py-1 text-xs text-[var(--lng1771-primary-50)] hover:bg-[var(--lng1771-primary-30)] focus:outline-none focus-visible:ring-1">({aggName as string})</button></D.Trigger><D.Portal><GridDropMenuContent><D.Arrow fill="var(--lng1771-gray-30)" /><D.DropdownMenuRadioGroupvalue={aggName}onValueChange={(c) => {grid.state.aggModel.set((prev) => {return { ...prev, [column.id]: { fn: c } };});}}>{options.map((c) => {return <RadioItem key={c} value={c} label={c} className="pl-1" />;})}</D.DropdownMenuRadioGroup></GridDropMenuContent></D.Portal></D.Root>);}const itemCls ="flex items-center text-sm text-ln-gray-80 cursor-pointer rounded-lg data-[highlighted]:bg-ln-gray-30 py-1 pr-2 px-0.5";const RadioItem = ({icon,...props}: Omit<ComponentProps<typeof D.DropdownMenuRadioItem>, "children"> & {icon?: ReactNode;label: ReactNode;}) => {return (<D.DropdownMenuRadioItem{...props}className={clsx(props.className, itemCls, "group", "data-[disabled]:text-ln-gray-30")}>{icon && <MenuIcon>{icon}</MenuIcon>}{props.label}<MenuIcon><TickmarkIconclassName="stroke-ln-primary-50 relative hidden group-data-[state='checked']:block"style={{ right: -16 }}/></MenuIcon></D.DropdownMenuRadioItem>);};const MenuIcon = (props: PropsWithChildren) => {return (<span className="text-ln-gray-70 mr-2 flex h-[24px] w-[20px] items-center justify-center">{props.children}</span>);};function GridDropMenuContent(props: PropsWithChildren) {return (<DropdownMenu.ContentclassName={tw("bg-ln-gray-05 border-ln-gray-30 z-50 rounded-lg border p-1")}>{props.children}</DropdownMenu.Content>);}
Preloading on hover is just one approach. You could also preload on load or when a cell gains focus. The core concept remains the same: before the user requests the data, the grid predicts that they'll want to expand the row and loads it ahead of time.
The code for preloading rows is even simpler than for optimistically fetching rows while scrolling:
<Grid.RowonMouseEnter={() => {const req = ds.requestForGroup(row.rowIndex);if (!req || ds.seenRequests.has(req.id)) return;// Preload the row groupds.seenRequests.add(req.id);ds.pushRequests([req]);}}>{...}</Grid.Row>
The code above uses another server data source method, requestForGroup
. This
method returns a DataRequest
object that can be used to request the child
rows of a group, allowing you to preload them in advance.
Next Steps
- Server Row Data: shows how to fetch server data in slices.
- Server Row Sorting: explains server-side sorting and how it is displayed client-side.
- Server Row Filtering: covers server-side filtering, including advanced features such as in filters (tree set filters) and quick search filters.
- Server Row Grouping and Aggregation: handles grouped data and loads group slices.
Unbalanced Rows (Tree Data)
Hierarchical data may include leaf rows at varying depths, which is especially common in NoSQL databases. LyteNyte Grid supports representing these unbalanced hierarchies and can load them from a server source on demand.
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.