Modern applications often work with datasets too large to load entirely in the browser. To preserve performance, LyteNyte Grid uses server-side (on-demand) data loading, transferring only the data currently required by the user interface.
The server row data source integrates with your backend to request data in blocks. Responses must adhere to the interfaces defined in the LyteNyte Grid server data source specification.
Use the useServerDataSource
hook to create a server-backed data source. At a minimum, you must
provide a dataFetcher
function that retrieves rows from your server.
Throughout this guide, we use mock implementations of dataFetcher
for
clarity. Replace these with real server calls in production.
The dataFetcher
function is the core of your implementation. It receives a
DataFetcherParams
object, which includes:
model
: the current request model (sorts, filters, groups, aggregations, etc.)requests
: an array of DataRequest
objects, one for each block of rows the grid requires.export interface DataRequestModel<T> {
readonly sorts: SortModelItem<T>[];
readonly filters: Record<string, FilterModelItem<T>>;
readonly filtersIn: Record<string, FilterIn>;
readonly quickSearch: string | null;
readonly group: RowGroupModelItem<T>[];
readonly groupExpansions: Record<string, boolean | undefined>;
readonly aggregations: Record<string, { fn: AggModelFn<T> }>;
readonly pivotGroupExpansions: Record<string, boolean | undefined>;
readonly pivotMode: boolean;
readonly pivotModel: ColumnPivotModel<T>;
}
This model mirrors the current grid configuration. For example, if the grid has a sort applied,
model.sorts
will contain the relevant sort configuration.
Each request describes the specific rows the grid needs:
export interface DataRequest {
readonly id: string;
readonly path: (string | null)[];
readonly start: number;
readonly end: number;
readonly rowStartIndex: number;
readonly rowEndIndex: number;
}
id
uniquely identifies the request (useful for caching).path
describes grouping context (empty for root).start
/end
define the requested range relative to the path.Your server must return one or more responses per request. These can be optimistic (i.e., include more rows than requested). The return type is:
Promise<(DataResponse | DataResponsePinned)[]>;
export interface DataResponse {
readonly kind: "center";
readonly data: (DataResponseLeafItem | DataResponseBranchItem)[];
readonly size: number;
readonly asOfTime: number;
readonly path: (string | null)[];
readonly start: number;
readonly end: number;
}
The data
array may include leaf rows or branch rows, depending on grouping.
If you send additional rows beyond the request, ensure parent branches are also included so the hierarchy is consistent.
export interface DataResponsePinned {
readonly kind: "top" | "bottom";
readonly data: DataResponseLeafItem[];
readonly asOfTime: number;
}
Pinned rows (top
or bottom
) are never explicitly requested by the grid. If needed, include them
alongside your standard responses.
"use client";
import { bankDataSmall as bankData } from "@1771technologies/sample-data/bank-data-smaller";
import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";
import "@1771technologies/lytenyte-pro/grid.css";
import type {
Column,
DataRequest,
DataRequestModel,
DataResponse,
DataResponseBranchItem,
DataResponseLeafItem,
DataResponsePinned,
} from "@1771technologies/lytenyte-pro/types";
import type { bankDataSmall } from "@1771technologies/sample-data/bank-data-smaller";
import { useId } from "react";
import sql from "alasql";
type BankData = (typeof bankDataSmall)[number];
const columns: Column<BankData>[] = [
{ id: "age", type: "number" },
{ id: "job", hide: true },
{ id: "balance", type: "number" },
{ id: "education" },
{ id: "marital" },
{ id: "default" },
{ id: "housing" },
];
export default function App() {
const ds = useServerDataSource<BankData>({
dataFetcher: async (p) => {
const res = await handleRequest(p.requests, p.model);
return res;
},
});
const grid = Grid.useLyteNyte({
gridId: useId(),
rowDataSource: ds,
columns,
});
const view = grid.view.useValue();
return (
<div className="lng-grid" style={{ height: 500 }}>
<Grid.Root grid={grid}>
<Grid.Viewport>
<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.HeaderCell
key={c.id}
cell={c}
className="flex w-full h-full capitalize px-2 items-center"
/>
);
})}
</Grid.HeaderRow>
);
})}
</Grid.Header>
<Grid.RowsContainer>
<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.Cell
key={c.id}
cell={c}
className="text-sm flex items-center px-2 h-full w-full"
/>
);
})}
</Grid.Row>
);
})}
</Grid.RowsCenter>
</Grid.RowsContainer>
</Grid.Viewport>
</Grid.Root>
</div>
);
}
// Create our server db. This is for illustration purposes only
sql(`
CREATE TABLE IF NOT EXISTS banks
(
age number,
job string,
balance number,
education string,
marital string,
_default string,
housing string,
loan string,
contact string,
day number,
month string,
duration number,
campaign number,
pdays number,
previous number,
poutcome string,
y string
)
`);
sql.tables.banks.data = bankData;
export async function handleRequest(
request: DataRequest[],
model: DataRequestModel<any>
): Promise<(DataResponsePinned | DataResponse)[]> {
const responses = request.map<DataResponse>((c) => {
// This is a root request
const limit = c.end - c.start;
const hasWhere = model.quickSearch || model.filters.length;
const groupKey = model.group[c.path.length];
if (groupKey) {
const data = sql<{ childCnt: number; pathKey: string }[]>(
`SELECT *, ${groupKey} AS pathKey, count(*) AS childCnt
FROM banks GROUP BY ${groupKey} LIMIT ${limit} OFFSET ${c.start}`
);
const cnt = sql<{ cnt: number }[]>(
`SELECT count(*) AS cnt FROM banks GROUP BY ${groupKey}`
).length;
return {
asOfTime: Date.now(),
data: data.map<DataResponseBranchItem>((row, i) => {
return {
kind: "branch",
childCount: row.childCnt,
data: row,
id: `${c.path.join("/")}__${i + c.start}`,
key: row.pathKey,
};
}),
start: c.start,
end: c.end,
kind: "center",
path: c.path,
size: cnt,
};
}
const data = sql<any[]>(`
WITH
flat AS (
SELECT
*
FROM
banks
${hasWhere ? "WHERE" : ""}
${getQuickSearchFilter(model.quickSearch)}
${model.quickSearch && model.filters.length ? "AND" : ""}
${getOrderByClauseForSorts(model.sorts)}
LIMIT ${limit} OFFSET ${c.start}
)
SELECT * FROM flat
`);
const count = sql<{ cnt: number }[]>(`
WITH
flat AS (
SELECT
*
FROM
banks
${hasWhere ? "WHERE" : ""}
${getQuickSearchFilter(model.quickSearch)}
${model.quickSearch && model.filters.length ? "AND" : ""}
${getOrderByClauseForSorts(model.sorts)}
)
SELECT count(*) as cnt FROM flat
`)[0].cnt;
return {
asOfTime: Date.now(),
data: data.map<DataResponseLeafItem>((row, i) => {
return {
data: row,
id: `${c.path.join("-->")}${i + c.start}`,
kind: "leaf",
};
}),
start: c.start,
end: c.end,
kind: "center",
path: c.path,
size: count,
};
});
return [
...responses,
{
kind: "top",
asOfTime: Date.now(),
data: [
{ kind: "leaf", data: {}, id: "t-1" },
{ kind: "leaf", data: {}, id: "t-2" },
],
},
{
kind: "bottom",
asOfTime: Date.now(),
data: [
{ kind: "leaf", data: {}, id: "b-1" },
{ kind: "leaf", data: {}, id: "b-2" },
],
},
];
}
function getOrderByClauseForSorts(sorts: DataRequestModel<any>["sorts"]) {
if (sorts.length === 0) return "";
const orderByStrings = sorts.map(
(c) => `${c.columnId} ${c.isDescending ? "DESC" : "ASC"}`
);
return `
ORDER BY
${orderByStrings.join(",\n")}
`;
}
function getQuickSearchFilter(
quickSearch: DataRequestModel<any>["quickSearch"]
) {
if (!quickSearch) return "";
const qs = `'%${quickSearch}%'`;
return `
(job LIKE ${qs}
OR education LIKE ${qs}
OR marital LIKE ${qs})
`;
}
Grouping increases the number of blocks the server must manage. Each group level requires responses for its subset of rows.
"use client";
import { bankDataSmall as bankData } from "@1771technologies/sample-data/bank-data-smaller";
import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";
import "@1771technologies/lytenyte-pro/grid.css";
import type {
Column,
DataRequest,
DataRequestModel,
DataResponse,
DataResponseBranchItem,
DataResponseLeafItem,
DataResponsePinned,
} from "@1771technologies/lytenyte-pro/types";
import type { bankDataSmall } from "@1771technologies/sample-data/bank-data-smaller";
import { useId } from "react";
import sql from "alasql";
import {
ChevronDownIcon,
ChevronRightIcon,
} from "@1771technologies/lytenyte-pro/icons";
type BankData = (typeof bankDataSmall)[number];
const columns: Column<BankData>[] = [
{ id: "age", type: "number" },
{ id: "job", hide: true },
{ id: "balance", type: "number" },
{ id: "education" },
{ id: "marital" },
{ id: "default" },
{ id: "housing" },
];
export default function App() {
const ds = useServerDataSource<BankData>({
dataFetcher: async (p) => {
const res = await handleRequest(p.requests, p.model);
return res;
},
});
const grid = Grid.useLyteNyte({
gridId: useId(),
rowDataSource: ds,
rowGroupModel: ["job", "education"],
columns,
rowGroupColumn: {
cellRenderer: ({ grid, row, column }) => {
if (!grid.api.rowIsGroup(row)) return null;
const field = grid.api.columnField(column, row);
const isExpanded = grid.api.rowGroupIsExpanded(row);
console.log(row.loading);
return (
<div
className="flex items-center gap-2 w-full h-full"
style={{ paddingLeft: row.depth * 16 }}
>
<button
className="flex items-center justify-center"
disabled={row.loading}
onClick={(e) => {
e.stopPropagation();
grid.api.rowGroupToggle(row);
}}
>
{row.loading && <Spinner />}
{!row.loading && !isExpanded && <ChevronRightIcon />}
{!row.loading && isExpanded && <ChevronDownIcon />}
</button>
<div>{`${field}`}</div>
</div>
);
},
},
});
const view = grid.view.useValue();
return (
<div className="lng-grid" style={{ height: 500 }}>
<Grid.Root grid={grid}>
<Grid.Viewport suppressHydrationWarning>
<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.HeaderCell
key={c.id}
cell={c}
className="flex w-full h-full capitalize px-2 items-center"
/>
);
})}
</Grid.HeaderRow>
);
})}
</Grid.Header>
<Grid.RowsContainer>
<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.Cell
key={c.id}
cell={c}
className="text-sm flex items-center px-2 h-full w-full"
/>
);
})}
</Grid.Row>
);
})}
</Grid.RowsCenter>
</Grid.RowsContainer>
</Grid.Viewport>
</Grid.Root>
</div>
);
}
// Create our server db. This is for illustration purposes only
sql(`
CREATE TABLE IF NOT EXISTS banks
(
age number,
job string,
balance number,
education string,
marital string,
_default string,
housing string,
loan string,
contact string,
day number,
month string,
duration number,
campaign number,
pdays number,
previous number,
poutcome string,
y string
)
`);
sql.tables.banks.data = bankData;
export async function handleRequest(
request: DataRequest[],
model: DataRequestModel<any>
): Promise<(DataResponsePinned | DataResponse)[]> {
await new Promise((res) => setTimeout(res, 500));
const responses = request.map<DataResponse>((c) => {
// This is a root request
const limit = c.end - c.start;
const hasWhere = model.quickSearch || model.filters.length;
const groupKey = model.group[c.path.length];
if (groupKey) {
const data = sql<{ childCnt: number; pathKey: string }[]>(
`SELECT *, ${groupKey} AS pathKey, count(*) AS childCnt
FROM banks GROUP BY ${groupKey} LIMIT ${limit} OFFSET ${c.start}`
);
const cnt = sql<{ cnt: number }[]>(
`SELECT count(*) AS cnt FROM banks GROUP BY ${groupKey}`
).length;
return {
asOfTime: Date.now(),
data: data.map<DataResponseBranchItem>((row, i) => {
return {
kind: "branch",
childCount: row.childCnt,
data: row,
id: `${c.path.join("/")}__${i + c.start}`,
key: row.pathKey,
};
}),
start: c.start,
end: c.end,
kind: "center",
path: c.path,
size: cnt,
};
}
const data = sql<any[]>(`
WITH
flat AS (
SELECT
*
FROM
banks
${hasWhere ? "WHERE" : ""}
${getQuickSearchFilter(model.quickSearch)}
${model.quickSearch && model.filters.length ? "AND" : ""}
${getOrderByClauseForSorts(model.sorts)}
LIMIT ${limit} OFFSET ${c.start}
)
SELECT * FROM flat
`);
const count = sql<{ cnt: number }[]>(`
WITH
flat AS (
SELECT
*
FROM
banks
${hasWhere ? "WHERE" : ""}
${getQuickSearchFilter(model.quickSearch)}
${model.quickSearch && model.filters.length ? "AND" : ""}
${getOrderByClauseForSorts(model.sorts)}
)
SELECT count(*) as cnt FROM flat
`)[0].cnt;
return {
asOfTime: Date.now(),
data: data.map<DataResponseLeafItem>((row, i) => {
return {
data: row,
id: `${c.path.join("-->")}${i + c.start}`,
kind: "leaf",
};
}),
start: c.start,
end: c.end,
kind: "center",
path: c.path,
size: count,
};
});
return [
...responses,
{
kind: "top",
asOfTime: Date.now(),
data: [
{ kind: "leaf", data: {}, id: "t-1" },
{ kind: "leaf", data: {}, id: "t-2" },
],
},
{
kind: "bottom",
asOfTime: Date.now(),
data: [
{ kind: "leaf", data: {}, id: "b-1" },
{ kind: "leaf", data: {}, id: "b-2" },
],
},
];
}
function getOrderByClauseForSorts(sorts: DataRequestModel<any>["sorts"]) {
if (sorts.length === 0) return "";
const orderByStrings = sorts.map(
(c) => `${c.columnId} ${c.isDescending ? "DESC" : "ASC"}`
);
return `
ORDER BY
${orderByStrings.join(",\n")}
`;
}
function getQuickSearchFilter(
quickSearch: DataRequestModel<any>["quickSearch"]
) {
if (!quickSearch) return "";
const qs = `'%${quickSearch}%'`;
return `
(job LIKE ${qs}
OR education LIKE ${qs}
OR marital LIKE ${qs})
`;
}
import React from "react";
function Spinner({
size = 16,
color = "currentColor",
strokeWidth = 2,
className = "",
label = "Loading…",
}) {
const radius = 10; // SVG units
const circumference = 2 * Math.PI * radius;
return (
<span
role="status"
aria-live="polite"
aria-busy="true"
className={`inline-flex items-center justify-center ${className}`}
style={{ width: size, height: size, lineHeight: 0 }}
>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="spinner"
aria-hidden="true"
focusable="false"
>
{/* Track */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke={color}
opacity="0.2"
strokeWidth={strokeWidth}
/>
{/* Arc that spins */}
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={`${circumference * 0.25} ${circumference}`}
className="spinner__arc"
/>
</svg>
<span className="sr-only">{label}</span>
{/* Styles scoped to this component render */}
<style>{`
@keyframes spinner-rotate { to { transform: rotate(360deg); } }
.spinner { display: block; }
.spinner__arc { transform-origin: 12px 12px; animation: spinner-rotate 0.75s linear infinite; }
/* Visually hidden but accessible text */
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
`}</style>
</span>
);
}
The useServerDataSource
hook accepts a blockSize
option that determines how many rows are
requested per block:
Adjust this value based on expected dataset size and user bandwidth.
Enable pivoting by providing dataColumnPivotFetcher
to useServerDataSource
. This async callback
fetches pivot column definitions for the current configuration. The grid automatically calls it when
pivot mode is enabled.
The server row data source is the most flexible, but also the most complex option. The server is responsible for applying: