Paginated Data Loading
Paginated Row Sorting
Request sorted rows by page by defining a sort model that you send to the server.
Sorting Paginated 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 paginated row sorting. When you click a column header, the grid updates that column’s sort state.
Sorted Paginated 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 pageSize = 10;45
46const base: Grid.ColumnBase<GridSpec> = { headerRenderer: Header };47const formatter = Intl.NumberFormat("en-Us", { minimumFractionDigits: 0, maximumFractionDigits: 0 });48
49const headerHeight = 40;50const rowHeight = 40;51
52export default function PaginationDemo() {53 const [columns, setColumns] = useState(initialColumns);54 const [page, setPage] = useState(0);55 const [count, setCount] = useState<number | null>(null);56
57 const sort = useMemo(() => {58 const columnWithSort = columns.find((x) => x.sort);59 if (!columnWithSort) return null;60
61 return { columnId: columnWithSort.id, isDescending: columnWithSort.sort === "desc" };62 }, [columns]);63
64 const responseCache = useRef<Record<number, DataResponse[]>>({});65
66 const ds = useServerDataSource({67 queryFn: async ({ requests, queryKey }) => {68 const page = queryKey[0];69 const sort = queryKey[1];70
71 if (responseCache.current[page]) {72 return responseCache.current[page].map((x) => ({ ...x, asOfTime: Date.now() }));73 }74
75 const result = await Server(requests, page, pageSize, sort);76 responseCache.current[page] = result.pages;77
78 setCount(result.count);79
80 return result.pages;81 },82 queryKey: [page, sort] as const,83 });84
85 const isLoading = ds.isLoading.useValue();86
87 const apiExtension = useMemo<GridSpec["api"]>(() => {88 return {89 sortColumn: (id, dir) => {90 setColumns((prev) => {91 responseCache.current = {};92 const next = prev.map((x) => {93 // Remove any existing sort94 if (x.sort && x.id !== id) {95 const next = { ...x };96 delete next.sort;97 return next;98 }99 // Apply our new sort100 if (x.id === id) {101 const next = { ...x };102 if (dir == null) delete next.sort;103 else next.sort = dir;104
105 return next;106 }107 return x;108 });109 return next;110 });111 },112 };113 }, []);114
115 return (116 <div>117 <div className="ln-grid" style={{ height: pageSize * rowHeight + headerHeight }}>118 <Grid119 rowSource={ds}120 columns={columns}121 headerHeight={headerHeight}122 rowHeight={rowHeight}123 apiExtension={apiExtension}124 columnBase={base}125 slotViewportOverlay={126 isLoading && (127 <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>128 )129 }130 />131 </div>132 <div className="border-ln-border flex h-12 items-center justify-end gap-4 border-t px-4">133 {count && (134 <>135 <div className="text-sm tabular-nums">136 {formatter.format(page * pageSize + 1)}-{formatter.format(page * pageSize + pageSize)} of{" "}137 {formatter.format(count)}138 </div>139 <div className="flex items-center">140 <button141 data-ln-button="tertiary"142 data-ln-size="lg"143 className="rounded-e-none"144 onClick={() => setPage((prev) => Math.max(0, prev - 1))}145 >146 <svg width="14" height="14" fill="currentcolor" viewBox="0 0 256 256">147 <path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path>148 </svg>149 </button>150 <button151 data-ln-button="tertiary"152 data-ln-size="lg"153 className="rounded-s-none border-s-0"154 onClick={() => setPage((prev) => Math.min(Math.ceil(count / pageSize) - 1, prev + 1))}155 >156 <svg width="14" height="14" fill="currentcolor" viewBox="0 0 256 256">157 <path d="M181.66,133.66l-80,80A8,8,0,0,1,88,208V48a8,8,0,0,1,13.66-5.66l80,80A8,8,0,0,1,181.66,133.66Z"></path>158 </svg>159 </button>160 </div>161 </>162 )}163 </div>164 </div>165 );166}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 page: number,9 pageSize: number,10 sortModel: { columnId: string; isDescending: boolean } | null,11) {12 // Simulate latency and server work.13 await sleep();14
15 let data = rawData;16
17 if (sortModel) {18 data = rawData.toSorted((l, r) => {19 const id = sortModel.columnId as keyof MovieData;20
21 const leftValue = l[id];22 const rightValue = r[id];23
24 // Check null states before moving on to checking sort values25 if (!leftValue && !rightValue) return 0;26 else if (leftValue && !rightValue) return -1;27 else if (!leftValue && rightValue) return 1;28
29 let val = 0;30 if (id === "link" || id === "name" || id === "genre" || id === "type") {31 val = leftValue.localeCompare(rightValue);32 } else if (id === "released_at") {33 if (!leftValue && !rightValue) val = 0;34 else if (leftValue && !rightValue) val = -1;35 else if (!leftValue && rightValue) val = 1;36 else {37 const leftDate = new Date(leftValue);38 const rightDate = new Date(rightValue);39
40 if (leftDate < rightDate) val = -1;41 else if (leftDate > rightDate) val = 1;42 else val = 0;43 }44 } else if (id === "imdb_rating") {45 const left = Number.parseFloat(leftValue.split("/")[0]);46 const right = Number.parseFloat(rightValue.split("/")[0]);47
48 val = left - right;49 }50
51 if (val !== 0) return sortModel.isDescending ? -val : val;52
53 return val;54 });55 }56
57 const pageStart = page * pageSize;58
59 const pages = reqs.map((c) => {60 const pageData = data.slice(pageStart, pageStart + pageSize);61
62 return {63 asOfTime: Date.now(),64 data: pageData.map((x) => {65 return { kind: "leaf", id: x.uniq_id, data: x };66 }),67 start: c.start,68 end: c.end,69 kind: "center",70 path: c.path,71 size: Math.min(pageSize, pageData.length),72 } satisfies DataResponse;73 });74
75 return {76 pages,77 count: data.length,78 };79}The demo code applies sort state to individual 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
- Paginated Row Filtering: Filter rows and return data in page slices.
- Paginated Rows: Load rows one page at a time.
- Server Row Sorting: Sort rows on the server using a defined sort model.
