Paginated Rows
Pagination lets you load rows one page at a time using a cursor-based offset.
Note
Paginated row loading uses the server data source in LyteNyte Grid. You must already be familiar with the server data source before you read this guide.
Basic Pagination
Paginate rows by combining the server data source with a
page cursor. Set up pagination in a two-step process:
- Define a
pagevalue in React state, e.g. by using theuseStatehook. - Include the
pagevalue in thequeryKeyproperty of the server data source.
Then pass a queryFn function to LyteNyte Grid’s server data source. The queryFn function
loads the data for the current page. When page changes, LyteNyte Grid calls queryFn
to fetch the next slice of rows.
The demo below demonstrates the basic pagination setup with caching included, so that pages that have already been loaded resolve instantly when navigating back to a previous page.
Basic Paginated Implementation
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { Grid, useServerDataSource, type DataResponse } from "@1771technologies/lytenyte-pro";4
5import { useRef, useState } from "react";6import { Server } from "./server.jsx";7import type { MovieData } from "./data.js";8import {9 GenreRenderer,10 LinkRenderer,11 NameCellRenderer,12 RatingRenderer,13 ReleasedRenderer,14 TypeRenderer,15} from "./components.jsx";16
17export interface GridSpec {18 readonly data: MovieData;19}20
21const columns: Grid.Column<GridSpec>[] = [22 {23 id: "#",24 name: "",25 width: 30,26 field: "link",27 widthMin: 30,28 widthMax: 30,29 cellRenderer: LinkRenderer,30 },31 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },32 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },33 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },34 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },35 { id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },36];37
38const pageSize = 10;39
40const formatter = Intl.NumberFormat("en-Us", { minimumFractionDigits: 0, maximumFractionDigits: 0 });41
42const headerHeight = 40;43const rowHeight = 40;44
45export default function PaginationDemo() {46 const [page, setPage] = useState(0);47 const [count, setCount] = useState<number | null>(null);48
49 const responseCache = useRef<Record<number, DataResponse[]>>({});50
51 const ds = useServerDataSource<GridSpec["data"], [page: number]>({52 queryFn: async ({ requests, queryKey }) => {53 const page = queryKey[0];54
55 if (responseCache.current[page]) {56 return responseCache.current[page].map((x) => ({ ...x, asOfTime: Date.now() }));57 }58
59 const result = await Server(requests, page, pageSize);60 responseCache.current[page] = result.pages;61
62 setCount(result.count);63
64 return result.pages;65 },66 queryKey: [page],67 });68
69 const isLoading = ds.isLoading.useValue();70
71 return (72 <div>73 <div className="ln-grid" style={{ height: pageSize * rowHeight + headerHeight }}>74 <Grid75 rowSource={ds}76 columns={columns}77 headerHeight={headerHeight}78 rowHeight={rowHeight}79 slotViewportOverlay={80 isLoading && (81 <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>82 )83 }84 />85 </div>86 <div className="border-ln-border flex h-12 items-center justify-end gap-4 border-t px-4">87 {count && (88 <>89 <div className="text-sm tabular-nums">90 {formatter.format(page * pageSize + 1)}-{formatter.format(page * pageSize + pageSize)} of{" "}91 {formatter.format(count)}92 </div>93 <div className="flex items-center">94 <button95 data-ln-button="tertiary"96 data-ln-size="lg"97 className="rounded-e-none"98 onClick={() => setPage((prev) => Math.max(0, prev - 1))}99 >100 <svg width="14" height="14" fill="currentcolor" viewBox="0 0 256 256">101 <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>102 </svg>103 </button>104 <button105 data-ln-button="tertiary"106 data-ln-size="lg"107 className="rounded-s-none border-s-0"108 onClick={() => setPage((prev) => Math.min(Math.ceil(count / pageSize) - 1, prev + 1))}109 >110 <svg width="14" height="14" fill="currentcolor" viewBox="0 0 256 256">111 <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>112 </svg>113 </button>114 </div>115 </>116 )}117 </div>118 </div>119 );120}1import { format } from "date-fns";2import type { JSX } from "react";3import { Rating, ThinRoundedStar } from "@smastrom/react-rating";4import "@smastrom/react-rating/style.css";5import { 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};1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import { data } from "./data.js";3
4const sleep = () => new Promise((res) => setTimeout(res, 200));5
6export async function Server(reqs: DataRequest[], page: number, pageSize: number) {7 // Simulate latency and server work.8 await sleep();9
10 const pageStart = page * pageSize;11
12 const pages = reqs.map((c) => {13 const pageData = data.slice(pageStart, pageStart + pageSize);14
15 return {16 asOfTime: Date.now(),17 data: pageData.map((x) => {18 return { kind: "leaf", id: x.uniq_id, data: x };19 }),20 start: c.start,21 end: c.end,22 kind: "center",23 path: c.path,24 size: Math.min(pageSize, pageData.length),25 } satisfies DataResponse;26 });27
28 return {29 pages,30 count: data.length,31 };32}The demo retrieves the data for the current page. The server also sends the total row count, which you can use to compute the number of pages available.
1const responseCache = useRef<Record<number, DataResponse[]>>({});2
3const ds = useServerDataSource<GridSpec["data"], [page: number]>({4 queryFn: async ({ requests, queryKey }) => {5 const page = queryKey[0];6
7 if (responseCache.current[page]) {8 return responseCache.current[page].map((x) => ({ ...x, asOfTime: Date.now() }));9 }10
11 const result = await Server(requests, page, pageSize);12 responseCache.current[page] = result.pages;13
14 setCount(result.count);15 return result.pages;16 },17 queryKey: [page],18});Pagination Size
You can configure the number of rows requested per page by sending the page size state (pageSize) to the server, alongside the
current page value. If you include the page size in the queryKey of the server data
source, changes to the page size will reset the grid view and re-request pages from the server.
Sizing Pagination
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 LinkRenderer,11 NameCellRenderer,12 RatingRenderer,13 ReleasedRenderer,14 TypeRenderer,15} from "./components.jsx";16import { Menu } from "@1771technologies/lytenyte-pro/components";17import { ChevronDown } from "lucide-react";18
19export interface GridSpec {20 readonly data: MovieData;21}22
23const columns: Grid.Column<GridSpec>[] = [24 {25 id: "#",26 name: "",27 width: 30,28 field: "link",29 widthMin: 30,30 widthMax: 30,31 cellRenderer: LinkRenderer,32 },33 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },34 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },35 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },36 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },37 { id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },38];39
40const formatter = Intl.NumberFormat("en-Us", { minimumFractionDigits: 0, maximumFractionDigits: 0 });41
42const headerHeight = 40;43const rowHeight = 40;44
45export default function PaginationDemo() {46 const [pageSize, setPageSize] = useState(10);47 const [page, setPage] = useState(0);48 const [count, setCount] = useState<number | null>(null);49
50 const responseCache = useRef<Record<number, DataResponse[]>>({});51
52 const ds = useServerDataSource<GridSpec["data"], [page: number, pageSize: number]>({53 queryFn: async ({ requests, queryKey }) => {54 const page = queryKey[0];55
56 if (responseCache.current[page]) {57 return responseCache.current[page].map((x) => ({ ...x, asOfTime: Date.now() }));58 }59
60 const result = await Server(requests, page, pageSize);61 responseCache.current[page] = result.pages;62
63 setCount(result.count);64
65 return result.pages;66 },67 queryKey: [page, pageSize],68 });69
70 const isLoading = ds.isLoading.useValue();71
72 return (73 <div>74 <div className="ln-grid" style={{ height: 10 * rowHeight + headerHeight }}>75 <Grid76 rowSource={ds}77 columns={columns}78 headerHeight={headerHeight}79 rowHeight={rowHeight}80 styles={useMemo(() => {81 if (pageSize === 10) return {};82 return { viewport: { style: { scrollbarGutter: "stable" } } };83 }, [pageSize])}84 slotViewportOverlay={85 isLoading && (86 <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>87 )88 }89 />90 </div>91 <div className="border-ln-border flex h-12 items-center justify-end gap-4 border-t px-4">92 {count && (93 <>94 <div className="flex items-center gap-3">95 Rows per page96 <Menu>97 <Menu.Trigger data-ln-button="tertiary" data-ln-size="lg" className="gap-2">98 {pageSize} <ChevronDown className="size-4" />99 </Menu.Trigger>100 <Menu.Popover>101 <Menu.Container className="min-w-12">102 {[10, 20, 30, 40, 50].map((size) => {103 return (104 <Menu.Item105 key={size}106 onAction={() => {107 responseCache.current = {};108 setPageSize(size);109 setPage(0);110 }}111 >112 {size}113 </Menu.Item>114 );115 })}116 </Menu.Container>117 </Menu.Popover>118 </Menu>119 </div>120 <div className="text-sm tabular-nums">121 {formatter.format(page * pageSize + 1)}-{formatter.format(page * pageSize + pageSize)} of{" "}122 {formatter.format(count)}123 </div>124 <div className="flex items-center">125 <button126 data-ln-button="tertiary"127 data-ln-size="lg"128 className="rounded-e-none"129 onClick={() => setPage((prev) => Math.max(0, prev - 1))}130 >131 <svg width="14" height="14" fill="currentcolor" viewBox="0 0 256 256">132 <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>133 </svg>134 </button>135 <button136 data-ln-button="tertiary"137 data-ln-size="lg"138 className="rounded-s-none border-s-0"139 onClick={() => setPage((prev) => Math.min(Math.ceil(count / pageSize) - 1, prev + 1))}140 >141 <svg width="14" height="14" fill="currentcolor" viewBox="0 0 256 256">142 <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>143 </svg>144 </button>145 </div>146 </>147 )}148 </div>149 </div>150 );151}1import { format } from "date-fns";2import type { JSX } from "react";3import { Rating, ThinRoundedStar } from "@smastrom/react-rating";4import "@smastrom/react-rating/style.css";5import { 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};1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import { data } from "./data.js";3
4const sleep = () => new Promise((res) => setTimeout(res, 200));5
6export async function Server(reqs: DataRequest[], page: number, pageSize: number) {7 // Simulate latency and server work.8 await sleep();9
10 const pageStart = page * pageSize;11
12 const pages = reqs.map((c) => {13 const pageData = data.slice(pageStart, pageStart + pageSize);14
15 return {16 asOfTime: Date.now(),17 data: pageData.map((x) => {18 return { kind: "leaf", id: x.uniq_id, data: x };19 }),20 start: c.start,21 end: c.end,22 kind: "center",23 path: c.path,24 size: Math.min(pageSize, pageData.length),25 } satisfies DataResponse;26 });27
28 return {29 pages,30 count: data.length,31 };32}The page size is a normal piece of state. The Show per page menu can be used to update the page size. When the page size is updated, the demo will reset the response cache, reset the page number, and apply the new page size.
Since the pageSize value is included in the queryKey for the server data source, the grid will
reset and request a new set of rows for the new page size.
Next Steps
- Paginated Row Sorting: Sort paginated rows on the server and request sorted slices.
- Paginated Row Filtering: Filter rows and return data in page slices.
- Data Interface: Request and response interface of the LyteNyte Grid server data source.
