Paginated Row Sorting
Use the server data source to send a sort model to the server, which sorts the rows before returning each ordered page.
Note
This guide covers sorting on paginated server row data. For client-side sorting, see the Client Row Sorting guide.
Sorting Paginated Rows
To sort rows on the server, define a sort model and send it with each request.
The useServerDataSource hook accepts the sort model as part of the queryKey.
The sort model can use any structure that fits your application.
In most cases, match the format your server or database expects.
The demo below shows paginated row sorting. Click a column header to update that column’s sort state.
Row Pagination Sorting
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";17import { Pager } from "./pager.jsx";18
19export interface GridSpec {20 readonly data: MovieData;21 readonly column: { sort?: "asc" | "desc" };22 readonly api: {23 sortColumn: (id: string, dir: "asc" | "desc" | null) => void;24 };25}26
27const initialColumns: Grid.Column<GridSpec>[] = [28 {29 id: "#",30 name: "",31 width: 30,32 field: "link",33 widthMin: 30,34 widthMax: 30,35 cellRenderer: LinkRenderer,36 headerRenderer: () => <div></div>,37 },38 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },39 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },40 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },41 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },42 { id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },43];44
45const pageSize = 10;46
47const base: Grid.ColumnBase<GridSpec> = { headerRenderer: Header };48
49const headerHeight = 40;50const rowHeight = 40;51
52export default function PaginationDemo() {53 const [columns, setColumns] = useState(initialColumns);54 const [page, setPage] = useState(1);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 - 1, 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 && <Pager page={page} pageSize={pageSize} count={count} onPageChange={setPage} />}134 </div>135 </div>136 );137}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 React, { useEffect, useState } from "react";2import "@1771technologies/lytenyte-pro/components.css";3import { useMemo } from "react";4
5const formatter = Intl.NumberFormat("en-Us", { minimumFractionDigits: 0, maximumFractionDigits: 0 });6
7export function Pager({8 page,9 pageSize,10 count,11 onPageChange,12}: {13 pageSize: number;14 page: number;15 count: number;16 onPageChange: (page: number) => void;17}) {18 const pageCount = useMemo(() => {19 return Math.ceil(count / pageSize);20 }, [count, pageSize]);21
22 return (23 <div className="flex w-full items-center justify-between">24 <div className="ms-3 text-xs tabular-nums md:text-sm">25 Rows{" "}26 <span className="font-bold">27 {formatter.format((page - 1) * pageSize + 1)}-{formatter.format(page * pageSize)}28 </span>{" "}29 of <span className="font-bold">{formatter.format(count)}</span>30 </div>31
32 <div className="flex items-center gap-1">33 <button data-ln-button="secondary" data-ln-size="md" onClick={() => onPageChange(1)}>34 <span className="sr-only">To first page</span>35 <svg36 xmlns="http://www.w3.org/2000/svg"37 width="16"38 height="16"39 fill="currentcolor"40 viewBox="0 0 256 256"41 >42 <path d="M200,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,200,48ZM72,40a8,8,0,0,0-8,8V208a8,8,0,0,0,16,0V48A8,8,0,0,0,72,40Z"></path>43 </svg>44 </button>45 <button46 data-ln-button="secondary"47 data-ln-size="md"48 onClick={() => onPageChange(Math.max(1, page - 1))}49 >50 <span className="sr-only">Previous Page</span>51 <svg52 xmlns="http://www.w3.org/2000/svg"53 width="16"54 height="16"55 fill="currentcolor"56 viewBox="0 0 256 256"57 >58 <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>59 </svg>60 </button>61 <div className="hidden items-center gap-1 px-2 tabular-nums md:flex">62 Page <IntegerNumberInput page={page} onPageChange={onPageChange} pageCount={pageCount} />63 of <span className="font-bold">{pageCount}</span>64 </div>65
66 <button67 data-ln-button="secondary"68 data-ln-size="md"69 onClick={() => onPageChange(Math.min(page + 1, pageCount))}70 >71 <span className="sr-only">Next page</span>72 <svg73 xmlns="http://www.w3.org/2000/svg"74 width="16"75 height="16"76 fill="currentcolor"77 viewBox="0 0 256 256"78 >79 <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>80 </svg>81 </button>82 <button data-ln-button="secondary" data-ln-size="md" onClick={() => onPageChange(pageCount)}>83 <span className="sr-only">To last page</span>84 <svg85 xmlns="http://www.w3.org/2000/svg"86 width="16"87 height="16"88 fill="currentcolor"89 viewBox="0 0 256 256"90 >91 <path d="M149.66,122.34a8,8,0,0,1,0,11.32l-80,80A8,8,0,0,1,56,208V48a8,8,0,0,1,13.66-5.66ZM184,40a8,8,0,0,0-8,8V208a8,8,0,0,0,16,0V48A8,8,0,0,0,184,40Z"></path>92 </svg>93 </button>94 </div>95 </div>96 );97}98
99interface IntegerNumberInputProps {100 page: number;101 onPageChange: (value: number) => void;102 pageCount: number;103}104
105function IntegerNumberInput({ page, onPageChange, pageCount }: IntegerNumberInputProps) {106 const [inputValue, setInputValue] = useState<string>(String(page));107
108 // Keep internal state in sync if page prop changes externally109 useEffect(() => {110 setInputValue(String(page));111 }, [page]);112
113 const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {114 const value = e.target.value;115
116 if (value === "") {117 setInputValue("");118 return;119 }120
121 if (/^\d+$/.test(value)) {122 const v = Math.max(Math.min(parseInt(value, 10), pageCount), 1);123 setInputValue(String(v));124 onPageChange(v);125 }126 };127
128 const handleBlur = () => {129 if (inputValue === "") {130 setInputValue(String(page));131 onPageChange(page);132 }133 };134
135 return (136 <input137 type="text"138 style={{ width: Math.max(`${inputValue}`.length, 3) * 12 + 18 }}139 className="text-center"140 data-ln-input141 inputMode="numeric"142 value={inputValue}143 onChange={handleChange}144 onBlur={handleBlur}145 />146 );147}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 let pageStart = page * pageSize;58
59 if (pageStart > data.length) {60 pageStart = data.length - pageSize;61 }62
63 const pages = reqs.map((c) => {64 const pageData = data.slice(pageStart, pageStart + pageSize);65
66 return {67 asOfTime: Date.now(),68 data: pageData.map((x) => {69 return { kind: "leaf", id: x.uniq_id, data: x };70 }),71 start: c.start,72 end: c.end,73 kind: "center",74 path: c.path,75 size: Math.min(pageSize, pageData.length),76 } satisfies DataResponse;77 });78
79 return {80 pages,81 count: data.length,82 };83}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 on the server before returning page slices to the client.
- Paginated Rows: Load large datasets by fetching rows one page at a time from the server.
- Server Row Sorting: Sort rows on the server using a defined sort model.
