Cell Editing Server Data
You can edit server-side data through the LyteNyte Grid server data source. This allows updating cell values and synchronizing those updates with the server.
This guide assumes familiarity with LyteNyte Grid's cell editing capabilities. If not, see the Cell Editing guide. The content here focuses on handling cell updates through the server data source.
Editing Data
LyteNyte Grid's useServerDataSource
hook accepts a callback for the cellUpdateHandler
property.
This handler is called when a cell edit occurs in the grid and is responsible for sending the update
request to the server. It runs asynchronously, and once the update completes, it's the
developer's responsibility to refresh the grid data, typically by calling the server data source's refresh
method.
The example below demonstrates this. You can edit any cell value (by double clicking on the cell); after committing the change, it won't update immediately but will refresh shortly to show the new value. This behavior isn't ideal, which is why the next section explains how to optimistically update the client value so changes appear instantly.
Edit Data Async
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import { handleUpdate, Server } from "./server";import { type SalaryData } from "./data";import {AgeCellRenderer,BaseCellRenderer,NumberEditor,NumberEditorInteger,SalaryRenderer,TextEditor,YearsOfExperienceRenderer,} from "./components";import clsx from "clsx";const columns: Column<SalaryData>[] = [{id: "Gender",width: 120,widthFlex: 1,cellRenderer: BaseCellRenderer,editRenderer: TextEditor,},{id: "Education Level",name: "Education",width: 160,widthFlex: 1,cellRenderer: BaseCellRenderer,editRenderer: TextEditor,},{id: "Age",type: "number",width: 100,widthFlex: 1,cellRenderer: AgeCellRenderer,editRenderer: NumberEditorInteger,},{id: "Years of Experience",name: "YoE",type: "number",width: 100,widthFlex: 1,cellRenderer: YearsOfExperienceRenderer,editRenderer: NumberEditorInteger,},{id: "Salary",type: "number",width: 160,widthFlex: 1,cellRenderer: SalaryRenderer,editRenderer: NumberEditor,},];export default function BasicServerData() {const resetKey = useId();const ds = useServerDataSource<SalaryData>({dataFetcher: (params) => {return Server(params.requests, resetKey);},cellUpdateHandler: async (updates) => {// send update to serverawait handleUpdate(updates, resetKey);// refresh after the updateds.refresh();},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,columnBase: {editable: true,},editCellMode: "cell",editClickActivator: "single",});const view = grid.view.useValue();return (<div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><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.HeaderCellkey={c.id}cell={c}className={clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end",)}/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><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.Cellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type { CellRendererParams, EditRenderer } from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import type { CSSProperties } from "react";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";import { twMerge } from "tailwind-merge";import type { ClassValue } from "clsx";import clsx from "clsx";function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}const formatter = new Intl.NumberFormat("en-Us", {minimumFractionDigits: 0,maximumFractionDigits: 0,});export function SalaryRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>);}export function YearsOfExperienceRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{field}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function AgeCellRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{formatter.format(field)}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function BaseCellRenderer({ row, column, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;const field = grid.api.columnField(column, row);return <div className="flex h-full w-full items-center">{field as string}</div>;}export function GroupCellRenderer({ row, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (grid.api.rowIsLeaf(row)) return <div />;const isExpanded = grid.api.rowGroupIsExpanded(row);return (<divstyle={{paddingLeft: row.depth * 16,"--before-offset": `${row.depth * 16 - 5}px`,} as CSSProperties}className={tw("relative flex h-full w-full items-center gap-2 overflow-hidden text-nowrap",row.depth > 0 &&"before:border-ln-gray-30 before:absolute before:left-[var(--before-offset)] before:top-0 before:h-full before:border-r before:border-dashed",)}>{row.loadingGroup && (<div className="w-5"><LoadingSpinner /></div>)}{!row.loadingGroup && (<buttonclassName="hover:bg-ln-gray-10 w-5 cursor-pointer rounded transition-colors"onClick={() => {grid.api.rowGroupToggle(row);}}><span className="sr-only">Toggle the row group</span>{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}</button>)}<div className="w-full overflow-hidden text-ellipsis">{row.key || "(none)"}</div></div>);}const LoadingSpinner = () => {return (<div className="flex min-h-screen items-center justify-center"><svgclassName="h-4 w-4 animate-spin text-blue-500"xmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"><circleclassName="opacity-25"cx="12"cy="12"r="8"stroke="currentColor"strokeWidth="4"></circle><pathclassName="opacity-75"fill="currentColor"d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path></svg></div>);};import { NumberInput } from "@ark-ui/react/number-input";import { ChevronUpIcon } from "@radix-ui/react-icons";export const NumberEditorInteger: EditRenderer<SalaryData> = ({ value, onChange }) => {return (<NumberInput.RootclassName="border-ln-gray-30 flex h-full w-full items-center rounded border"value={`${value}`}onValueChange={(d) => {onChange(Number.isNaN(d.valueAsNumber) ? 0 : d.valueAsNumber);}}min={0}max={100}allowOverflow={false}><NumberInput.InputclassName="w-[calc(100%-16px)] flex-1 focus:outline-none"onKeyDown={(e) => {if (e.key === "." || e.key === "," || e.key === "-") {e.preventDefault();e.stopPropagation();}}}/><NumberInput.Control className="flex w-[16px] flex-col"><NumberInput.IncrementTrigger><ChevronUpIcon /></NumberInput.IncrementTrigger><NumberInput.DecrementTrigger><ChevronDownIcon /></NumberInput.DecrementTrigger></NumberInput.Control></NumberInput.Root>);};export const NumberEditor: EditRenderer<SalaryData> = ({ value, onChange }) => {return (<NumberInput.RootclassName="border-ln-gray-30 flex h-full w-full items-center rounded border"value={`${value}`}onValueChange={(d) => {onChange(Number.isNaN(d.valueAsNumber) ? 0 : d.valueAsNumber);}}onKeyDown={(e) => {if (e.key === "," || e.key === "-") {e.preventDefault();e.stopPropagation();}}}min={0}allowOverflow={false}><NumberInput.Input className="w-[calc(100%-16px)] flex-1 focus:outline-none" /><NumberInput.Control className="flex w-[16px] flex-col"><NumberInput.IncrementTrigger><ChevronUpIcon /></NumberInput.IncrementTrigger><NumberInput.DecrementTrigger><ChevronDownIcon /></NumberInput.DecrementTrigger></NumberInput.Control></NumberInput.Root>);};export const TextEditor: EditRenderer<SalaryData> = ({ value, onChange }) => {return (<inputclassName="border-ln-gray-30 h-full w-full rounded border"value={`${value}`}onChange={(e) => onChange(e.target.value)}/>);};
import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import { data as rawData } from "./data";const sleep = (n = 600) => new Promise((res) => setTimeout(res, n));export async function handleUpdate(updates: Map<string, SalaryData>, resetKey: string) {await sleep(200);const data = dataMaps[resetKey]!;updates.forEach((x, id) => {const row = data.find((c) => c.id === id);if (!row) return;Object.assign(row, x);});}let dataMaps: Record<string, SalaryData[]> = {};export async function Server(reqs: DataRequest[], resetKey: string) {if (!dataMaps[resetKey]) {dataMaps = { [resetKey]: structuredClone(rawData) };}const data = dataMaps[resetKey];// Simulate latency and server work.await sleep();return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}
The example implements a basic update handler. In most cases, your update logic should look similar to the following:
cellUpdateHandler: async (updates) => {// send update to serverawait handleUpdate(updates);// refresh after the updateds.refresh();};
Optimistic Update For Instant Feedback
The example above isn't ideal for many use cases. The main issue is that cell updates aren't reflected
immediately. LyteNyte Grid's server data source provides an optimistic update flag that
applies cell updates directly on the client. Enabling this flag assumes the
server update will succeed. The cellUpdateOptimistically
property makes cell updates apply
instantly on the client, but remember that this change is client only. Developers
must still implement the cellUpdateHandler
function and handle any errors that occur when sending updates to the server.
Edit Data Async With Optimistic Updates
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import { handleUpdate, Server } from "./server";import type { SalaryData } from "./data";import {AgeCellRenderer,BaseCellRenderer,NumberEditor,NumberEditorInteger,SalaryRenderer,TextEditor,YearsOfExperienceRenderer,} from "./components";import clsx from "clsx";const columns: Column<SalaryData>[] = [{id: "Gender",width: 120,widthFlex: 1,cellRenderer: BaseCellRenderer,editRenderer: TextEditor,},{id: "Education Level",name: "Education",width: 160,widthFlex: 1,cellRenderer: BaseCellRenderer,editRenderer: TextEditor,},{id: "Age",type: "number",width: 100,widthFlex: 1,cellRenderer: AgeCellRenderer,editRenderer: NumberEditorInteger,},{id: "Years of Experience",name: "YoE",type: "number",width: 100,widthFlex: 1,cellRenderer: YearsOfExperienceRenderer,editRenderer: NumberEditorInteger,},{id: "Salary",type: "number",width: 160,widthFlex: 1,cellRenderer: SalaryRenderer,editRenderer: NumberEditor,},];export default function BasicServerData() {const resetKey = useId();const ds = useServerDataSource<SalaryData>({dataFetcher: (params) => {return Server(params.requests, resetKey);},cellUpdateHandler: async (updates) => {// send update to serverawait handleUpdate(updates, resetKey);},cellUpdateOptimistically: true,blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,columnBase: {editable: true,},editCellMode: "cell",editClickActivator: "single",});const view = grid.view.useValue();return (<div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><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.HeaderCellkey={c.id}cell={c}className={clsx("flex h-full w-full items-center px-2 text-sm capitalize",c.column.type === "number" && "justify-end",)}/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><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.Cellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type { CellRendererParams, EditRenderer } from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import type { CSSProperties } from "react";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";import { twMerge } from "tailwind-merge";import type { ClassValue } from "clsx";import clsx from "clsx";function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}const formatter = new Intl.NumberFormat("en-Us", {minimumFractionDigits: 0,maximumFractionDigits: 0,});export function SalaryRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>);}export function YearsOfExperienceRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{field}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function BaseCellRenderer({ row, column, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;const field = grid.api.columnField(column, row);return <div className="flex h-full w-full items-center">{field as string}</div>;}export function AgeCellRenderer({ grid, row, column }: CellRendererParams<SalaryData>) {const field = grid.api.columnField(column, row);if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (typeof field !== "number") return null;return (<div className="flex w-full items-baseline justify-end gap-1 tabular-nums"><span className="font-bold">{formatter.format(field)}</span>{" "}<span className="text-xs">{field <= 1 ? "Year" : "Years"}</span></div>);}export function GroupCellRenderer({ row, grid }: CellRendererParams<any>) {if (grid.api.rowIsLeaf(row) && row.loading) return <SkeletonLoading />;if (grid.api.rowIsLeaf(row)) return <div />;const isExpanded = grid.api.rowGroupIsExpanded(row);return (<divstyle={{paddingLeft: row.depth * 16,"--before-offset": `${row.depth * 16 - 5}px`,} as CSSProperties}className={tw("relative flex h-full w-full items-center gap-2 overflow-hidden text-nowrap",row.depth > 0 &&"before:border-ln-gray-30 before:absolute before:left-[var(--before-offset)] before:top-0 before:h-full before:border-r before:border-dashed",)}>{row.loadingGroup && (<div className="w-5"><LoadingSpinner /></div>)}{!row.loadingGroup && (<buttonclassName="hover:bg-ln-gray-10 w-5 cursor-pointer rounded transition-colors"onClick={() => {grid.api.rowGroupToggle(row);}}><span className="sr-only">Toggle the row group</span>{isExpanded ? <ChevronDownIcon /> : <ChevronRightIcon />}</button>)}<div className="w-full overflow-hidden text-ellipsis">{row.key || "(none)"}</div></div>);}const LoadingSpinner = () => {return (<div className="flex min-h-screen items-center justify-center"><svgclassName="h-4 w-4 animate-spin text-blue-500"xmlns="http://www.w3.org/2000/svg"fill="none"viewBox="0 0 24 24"><circleclassName="opacity-25"cx="12"cy="12"r="8"stroke="currentColor"strokeWidth="4"></circle><pathclassName="opacity-75"fill="currentColor"d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path></svg></div>);};import { NumberInput } from "@ark-ui/react/number-input";import { ChevronUpIcon } from "@radix-ui/react-icons";export const NumberEditorInteger: EditRenderer<SalaryData> = ({ value, onChange }) => {return (<NumberInput.RootclassName="border-ln-gray-30 flex h-full w-full items-center rounded border"value={`${value}`}onValueChange={(d) => {onChange(Number.isNaN(d.valueAsNumber) ? 0 : d.valueAsNumber);}}min={0}max={100}allowOverflow={false}><NumberInput.InputclassName="w-[calc(100%-16px)] flex-1 focus:outline-none"onKeyDown={(e) => {if (e.key === "." || e.key === "," || e.key === "-") {e.preventDefault();e.stopPropagation();}}}/><NumberInput.Control className="flex w-[16px] flex-col"><NumberInput.IncrementTrigger><ChevronUpIcon /></NumberInput.IncrementTrigger><NumberInput.DecrementTrigger><ChevronDownIcon /></NumberInput.DecrementTrigger></NumberInput.Control></NumberInput.Root>);};export const NumberEditor: EditRenderer<SalaryData> = ({ value, onChange }) => {return (<NumberInput.RootclassName="border-ln-gray-30 flex h-full w-full items-center rounded border"value={`${value}`}onValueChange={(d) => {onChange(Number.isNaN(d.valueAsNumber) ? 0 : d.valueAsNumber);}}onKeyDown={(e) => {if (e.key === "," || e.key === "-") {e.preventDefault();e.stopPropagation();}}}min={0}allowOverflow={false}><NumberInput.Input className="w-[calc(100%-16px)] flex-1 focus:outline-none" /><NumberInput.Control className="flex w-[16px] flex-col"><NumberInput.IncrementTrigger><ChevronUpIcon /></NumberInput.IncrementTrigger><NumberInput.DecrementTrigger><ChevronDownIcon /></NumberInput.DecrementTrigger></NumberInput.Control></NumberInput.Root>);};export const TextEditor: EditRenderer<SalaryData> = ({ value, onChange }) => {return (<inputclassName="border-ln-gray-30 h-full w-full rounded border"value={`${value}`}onChange={(e) => onChange(e.target.value)}/>);};
import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro/types";import type { SalaryData } from "./data";import { data as rawData } from "./data";const sleep = (n = 600) => new Promise((res) => setTimeout(res, n));export async function handleUpdate(updates: Map<string, SalaryData>, resetKey: string) {await sleep(200);const data = dataMaps[resetKey]!;updates.forEach((x, id) => {const row = data.find((c) => c.id === id);if (!row) return;Object.assign(row, x);});}let dataMaps: Record<string, SalaryData[]> = {};export async function Server(reqs: DataRequest[], resetKey: string) {if (!dataMaps[resetKey]) {dataMaps = { [resetKey]: structuredClone(rawData) };}const data = dataMaps[resetKey];// Simulate latency and server work.await sleep();return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}
With optimistic updates enabled, you can skip the refresh step if the applied update matches the expected server value. The server update logic now looks similar to:
cellUpdateHandler: async (updates) => {// send update to serverawait handleUpdate(updates);},cellUpdateOptimistically: true,
Next Steps
- Server Row Data: shows how to fetch server data in slices.
- Server Row Grouping and Aggregation: handle grouped data and load group slices.
- Optimistic Loading: explores LyteNyte Grid's optimistic loading for pre-fetching data, providing a responsive client-side experience.
Data Updates
Handle live data updates to display real-time views driven by server events. LyteNyte Grid's server data source requests update ticks from the server at a configurable frequency.
Unbalanced Rows (Tree Data)
Hierarchical data may include leaf rows at varying depths, which is especially common in NoSQL databases. LyteNyte Grid supports representing these unbalanced hierarchies and can load them from a server source on demand.