Infinite Data Loading
Infinite Row Sorting
Provide a sort model to the server data source to infinitely load sorted rows.
Sorting Infinite Rows
To sort rows on the server, define a sort model and include it in each data request. The useServerDataSource
hook accepts the sort model as part of the queryKey.
The sort model can take any shape that fits your application’s needs. In most cases, you should match the format that your server or database expects.
The demo below shows infinite row sorting. When you click a column header, the grid updates that column’s sort state.
Sorted Infinite Rows
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { Grid, useServerDataSource, type DataResponse } from "@1771technologies/lytenyte-pro";4
5import { useMemo, useRef, useState } from "react";6import { Server } from "./server.jsx";7import type { MovieData } from "./data.js";8import {9 GenreRenderer,10 Header,11 LinkRenderer,12 NameCellRenderer,13 RatingRenderer,14 ReleasedRenderer,15 TypeRenderer,16} from "./components.jsx";17
18export interface GridSpec {19 readonly data: MovieData;20 readonly column: { sort?: "asc" | "desc" };21 readonly api: {22 sortColumn: (id: string, dir: "asc" | "desc" | null) => void;23 };24}25
26const initialColumns: Grid.Column<GridSpec>[] = [27 {28 id: "#",29 name: "",30 width: 30,31 field: "link",32 widthMin: 30,33 widthMax: 30,34 cellRenderer: LinkRenderer,35 headerRenderer: () => <div></div>,36 },37 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },38 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },39 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },40 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },41 { id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },42];43
44const base: Grid.ColumnBase<GridSpec> = { headerRenderer: Header };45
46export default function PaginationDemo() {47 const [columns, setColumns] = useState(initialColumns);48
49 const sort = useMemo(() => {50 const columnWithSort = columns.find((x) => x.sort);51 if (!columnWithSort) return null;52
53 return { columnId: columnWithSort.id, isDescending: columnWithSort.sort === "desc" };54 }, [columns]);55
56 const responseCache = useRef<Record<number, DataResponse[]>>({});57
58 const ds = useServerDataSource({59 queryFn: async ({ requests, queryKey }) => {60 const sort = queryKey[0];61
62 return await Server(requests, sort);63 },64 queryKey: [sort] as const,65 });66
67 const isLoading = ds.isLoading.useValue();68
69 const apiExtension = useMemo<GridSpec["api"]>(() => {70 return {71 sortColumn: (id, dir) => {72 setColumns((prev) => {73 responseCache.current = {};74 const next = prev.map((x) => {75 // Remove any existing sort76 if (x.sort && x.id !== id) {77 const next = { ...x };78 delete next.sort;79 return next;80 }81 // Apply our new sort82 if (x.id === id) {83 const next = { ...x };84 if (dir == null) delete next.sort;85 else next.sort = dir;86
87 return next;88 }89 return x;90 });91 return next;92 });93 },94 };95 }, []);96
97 return (98 <div className="ln-grid" style={{ height: 500 }}>99 <Grid100 rowSource={ds}101 columns={columns}102 apiExtension={apiExtension}103 columnBase={base}104 events={useMemo<Grid.Events<GridSpec>>(() => {105 return {106 viewport: {107 scrollEnd: ({ viewport }) => {108 const top = viewport.scrollTop;109 const left = viewport.scrollHeight - viewport.clientHeight - top;110 if (left < 100) {111 const req = ds.requestsForView.get().at(-1)!;112 const next = { ...req, start: req.end, end: req.end + 100 };113 ds.pushRequests([next]);114 }115 },116 },117 };118 }, [ds])}119 styles={useMemo(() => {120 return { viewport: { style: { scrollbarGutter: "stable" } } };121 }, [])}122 slotViewportOverlay={123 isLoading && (124 <div className="bg-ln-gray-20/40 top-(--ln-top-offset) absolute left-0 z-20 h-[calc(100%-var(--ln-top-offset))] w-full animate-pulse"></div>125 )126 }127 />128 </div>129 );130}1import { format } from "date-fns";2import type { JSX } from "react";3import { Rating, ThinRoundedStar } from "@smastrom/react-rating";4import "@smastrom/react-rating/style.css";5import { ArrowDownIcon, ArrowUpIcon, Link1Icon } from "@radix-ui/react-icons";6import type { Grid } from "@1771technologies/lytenyte-pro";7import type { GridSpec } from "./demo";8
9function SkeletonLoading() {10 return (11 <div className="h-full w-full p-2">12 <div className="bg-ln-gray-20 h-full w-full animate-pulse rounded-xl"></div>13 </div>14 );15}16
17export const NameCellRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {18 if (params.row.loading && !params.row.data) return <SkeletonLoading />;19
20 const field = params.api.columnField(params.column, params.row) as string;21
22 return <div className="overflow-hidden text-ellipsis">{field}</div>;23};24
25export const ReleasedRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {26 if (params.row.loading && !params.row.data) return <SkeletonLoading />;27 const field = params.api.columnField(params.column, params.row) as string;28
29 const formatted = field ? format(field, "dd MMM yyyy") : "-";30
31 return <div>{formatted}</div>;32};33
34export const GenreRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {35 if (params.row.loading && !params.row.data) return <SkeletonLoading />;36 const field = params.api.columnField(params.column, params.row) as string;37
38 const splits = field ? field.split(",") : [];39
40 return (41 <div className="flex h-full w-full items-center gap-1">42 {splits.map((c) => {43 return (44 <div45 className="border-(--primary-200) text-(--primary-700) dark:text-(--primary-500) bg-(--primary-200)/20 rounded border p-1 px-2 text-xs"46 key={c}47 >48 {c}49 </div>50 );51 })}52 </div>53 );54};55
56const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {57 return (58 <svg59 xmlns="http://www.w3.org/2000/svg"60 width="20"61 height="20"62 fill="currentcolor"63 viewBox="0 0 256 256"64 {...props}65 >66 <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>67 </svg>68 );69};70
71const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {72 return (73 <svg74 xmlns="http://www.w3.org/2000/svg"75 width="20"76 height="20"77 fill="currentcolor"78 viewBox="0 0 256 256"79 {...props}80 >81 <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>82 </svg>83 );84};85
86export const TypeRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {87 if (params.row.loading && !params.row.data) return <SkeletonLoading />;88 const field = params.api.columnField(params.column, params.row) as string;89
90 const isMovie = field === "Movie";91 const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;92
93 return (94 <div className="flex h-full w-full items-center gap-2">95 <span className={isMovie ? "text-(--primary-500)" : "text-ln-primary-50"}>96 <Icon />97 </span>98 <span>{field}</span>99 </div>100 );101};102
103export const RatingRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {104 if (params.row.loading && !params.row.data) return <SkeletonLoading />;105 const field = params.api.columnField(params.column, params.row) as string;106 const rating = field ? Number.parseFloat(field.split("/")[0]) : null;107 if (rating == null || Number.isNaN(rating)) return "-";108
109 return (110 <div className="flex h-full w-full items-center">111 <Rating112 style={{ maxWidth: 100 }}113 halfFillMode="svg"114 value={Math.round(rating / 2)}115 itemStyles={{116 activeFillColor: "hsla(173, 78%, 34%, 1)",117 itemShapes: ThinRoundedStar,118 inactiveFillColor: "transparent",119 inactiveBoxBorderColor: "transparent",120 inactiveBoxColor: "transparent",121 inactiveStrokeColor: "transparent",122 }}123 readOnly124 />125 </div>126 );127};128
129export const LinkRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {130 if (params.row.loading && !params.row.data) return <SkeletonLoading />;131 const field = params.api.columnField(params.column, params.row) as string;132
133 return (134 <a href={field} className="text-(--primary-500)">135 <Link1Icon />136 </a>137 );138};139
140export function Header({ api, column }: Grid.T.HeaderParams<GridSpec>) {141 return (142 <div143 className="group relative flex h-full w-full cursor-pointer items-center px-1 text-sm transition-colors"144 onClick={() => {145 const nextSort = column.sort === "asc" ? null : column.sort === "desc" ? "asc" : "desc";146 api.sortColumn(column.id, nextSort);147 }}148 >149 <div className="sort-button flex w-full items-center justify-between rounded px-1 py-1 transition-colors">150 {column.name ?? column.id}151
152 {column.sort === "asc" && <ArrowUpIcon className="text-ln-text-dark size-4" />}153 {column.sort === "desc" && <ArrowDownIcon className="text-ln-text-dark size-4" />}154 </div>155 </div>156 );157}1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import { data as rawData, type MovieData } from "./data.js";3
4const sleep = () => new Promise((res) => setTimeout(res, 200));5
6export async function Server(7 reqs: DataRequest[],8 sortModel: { columnId: string; isDescending: boolean } | null,9) {10 // Simulate latency and server work.11 await sleep();12
13 let data = rawData;14
15 if (sortModel) {16 data = rawData.toSorted((l, r) => {17 const id = sortModel.columnId as keyof MovieData;18
19 const leftValue = l[id];20 const rightValue = r[id];21
22 // Check null states before moving on to checking sort values23 if (!leftValue && !rightValue) return 0;24 else if (leftValue && !rightValue) return -1;25 else if (!leftValue && rightValue) return 1;26
27 let val = 0;28 if (id === "link" || id === "name" || id === "genre" || id === "type") {29 val = leftValue.localeCompare(rightValue);30 } else if (id === "released_at") {31 if (!leftValue && !rightValue) val = 0;32 else if (leftValue && !rightValue) val = -1;33 else if (!leftValue && rightValue) val = 1;34 else {35 const leftDate = new Date(leftValue);36 const rightDate = new Date(rightValue);37
38 if (leftDate < rightDate) val = -1;39 else if (leftDate > rightDate) val = 1;40 else val = 0;41 }42 } else if (id === "imdb_rating") {43 const left = Number.parseFloat(leftValue.split("/")[0]);44 const right = Number.parseFloat(rightValue.split("/")[0]);45
46 val = left - right;47 }48
49 if (val !== 0) return sortModel.isDescending ? -val : val;50
51 return val;52 });53 }54
55 const pages = reqs.map((c) => {56 return {57 asOfTime: Date.now(),58 data: data.slice(c.start, c.end).map((x) => {59 return { kind: "leaf", id: x.uniq_id, data: x };60 }),61 start: c.start,62 end: c.end,63 kind: "center",64 path: c.path,65 size: Math.min(data.length, c.end),66 } satisfies DataResponse;67 });68
69 return pages;70}The demo code applies sort state to columns. By extending the grid’s API,
you can update the sort property on each column directly. The extended API then applies the appropriate
sort when the user clicks a column header.
Next Steps
- Infinite Row Filtering: Request filtered rows as the user scrolls infinitely.
- Infinite Rows: Load rows continuously as the user scrolls toward the bottom.
- Server Row Sorting: Sort rows on the server using a defined sort model.
