Cell Editing Server Data
Use the server data source to edit cell values and synchronize those updates with the server.
Note
This guide covers handling cell updates through the server data source. Before proceeding, see the Cell Editing guide.
Server Data Editing
The useServerDataSource hook accepts a callback for the onRowDataChange property. This
handler runs when the user edits a cell and sends the update request to the
server. The handler runs asynchronously. Once the update completes, you must refresh the grid data,
typically by calling the server data source’s refresh method.
In the demo below, double-click any cell to edit it. After you commit the change, the cell briefly retains its old value before the grid refreshes. To eliminate this delay, the next section explains how to optimistically update the client-side value so changes appear instantly.
Asynchronous Data Editing
1"use client";2
3import "@1771technologies/lytenyte-pro/light-dark.css";4import { useMemo, useState } from "react";5import { handleUpdate, Server } from "./server.js";6import { type SalaryData } from "./data.js";7import {8 AgeCellRenderer,9 BaseCellRenderer,10 NumberEditor,11 NumberEditorInteger,12 SalaryRenderer,13 TextEditor,14 YearsOfExperienceRenderer,15} from "./components.js";16import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";17
18export interface GridSpec {19 readonly data: SalaryData;20}21
22const columns: Grid.Column<GridSpec>[] = [23 {24 id: "Gender",25 width: 120,26 widthFlex: 1,27 cellRenderer: BaseCellRenderer,28 editRenderer: TextEditor,29 editable: true,30 },31 {32 id: "Education Level",33 name: "Education",34 width: 160,35 widthFlex: 1,36 cellRenderer: BaseCellRenderer,37 editRenderer: TextEditor,38 editable: true,39 },40 {41 id: "Age",42 type: "number",43 width: 100,44 widthFlex: 1,45 cellRenderer: AgeCellRenderer,46 editRenderer: NumberEditorInteger,47 editable: true,48 },49 {50 id: "Years of Experience",51 name: "YoE",52 type: "number",53 width: 100,54 widthFlex: 1,55 cellRenderer: YearsOfExperienceRenderer,56 editRenderer: NumberEditorInteger,57 editable: true,58 },59 {60 id: "Salary",61 type: "number",62 width: 160,63 widthFlex: 1,64 cellRenderer: SalaryRenderer,65 editRenderer: NumberEditor,66 editable: true,67 },68];69
70export default function ServerDataDemo() {71 // No relevant to demo. Used to reset server data.72 const [resetKey] = useState(() => `${Math.random()}`);73
74 const ds = useServerDataSource<SalaryData>({75 queryFn: (params) => {76 return Server(params.requests, resetKey);77 },78 queryKey: [],79 onRowDataChange: async ({ rows }) => {80 const updates = new Map([...rows.entries()].map(([key, row]) => [key.id, row]));81
82 await handleUpdate(updates, resetKey);83 ds.refresh();84 },85 blockSize: 50,86 });87
88 const isLoading = ds.isLoading.useValue();89
90 return (91 <div className="ln-grid" style={{ height: 500 }}>92 <Grid93 rowSource={ds}94 editMode="cell"95 editClickActivator="single-click"96 columns={columns}97 styles={useMemo(() => {98 return { viewport: { style: { scrollbarGutter: "stable" } } };99 }, [])}100 slotViewportOverlay={101 isLoading && (102 <div className="bg-ln-gray-20/40 absolute left-0 top-0 z-20 h-full w-full animate-pulse"></div>103 )104 }105 />106 </div>107 );108}1import { NumberInput } from "@ark-ui/react/number-input";2import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons";3import type { Grid } from "@1771technologies/lytenyte-pro";4import type { GridSpec } from "./demo";5
6function SkeletonLoading() {7 return (8 <div className="h-full w-full p-2">9 <div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div>10 </div>11 );12}13
14const formatter = new Intl.NumberFormat("en-Us", {15 minimumFractionDigits: 0,16 maximumFractionDigits: 0,17});18export function SalaryRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {19 const field = api.columnField(column, row);20
21 if (api.rowIsLeaf(row) && !row.data && row.loading) return <SkeletonLoading />;22
23 if (typeof field !== "number") return null;24
25 return <div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>;26}27
28export function YearsOfExperienceRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {29 const field = api.columnField(column, row);30
31 if (api.rowIsLeaf(row) && !row.data && row.loading) return <SkeletonLoading />;32
33 if (typeof field !== "number") return null;34
35 return (36 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">37 <span className="font-bold">{field}</span>{" "}38 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>39 </div>40 );41}42
43export function AgeCellRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {44 const field = api.columnField(column, row);45
46 if (api.rowIsLeaf(row) && !row.data && row.loading) return <SkeletonLoading />;47
48 if (typeof field !== "number") return null;49
50 return (51 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">52 <span className="font-bold">{formatter.format(field)}</span>{" "}53 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>54 </div>55 );56}57
58export function BaseCellRenderer({ row, column, api }: Grid.T.CellRendererParams<GridSpec>) {59 if (api.rowIsLeaf(row) && !row.data && row.loading) return <SkeletonLoading />;60
61 const field = api.columnField(column, row);62
63 return <div className="flex h-full w-full items-center">{field as string}</div>;64}65
66export const NumberEditorInteger = ({ editValue, changeValue }: Grid.T.EditParams<GridSpec>) => {67 return (68 <NumberInput.Root69 className="border-ln-gray-30 flex h-full w-full items-center rounded border"70 value={`${editValue}`}71 onValueChange={(d) => {72 changeValue(Number.isNaN(d.valueAsNumber) ? 0 : d.valueAsNumber);73 }}74 min={0}75 max={100}76 allowOverflow={false}77 >78 <NumberInput.Input79 className="w-[calc(100%-16px)] flex-1 focus:outline-none"80 onKeyDown={(e) => {81 if (e.key === "." || e.key === "," || e.key === "-") {82 e.preventDefault();83 e.stopPropagation();84 }85 }}86 />87 <NumberInput.Control className="flex w-4 flex-col">88 <NumberInput.IncrementTrigger>89 <ChevronUpIcon />90 </NumberInput.IncrementTrigger>91 <NumberInput.DecrementTrigger>92 <ChevronDownIcon />93 </NumberInput.DecrementTrigger>94 </NumberInput.Control>95 </NumberInput.Root>96 );97};98
99export const NumberEditor = ({ editValue, changeValue }: Grid.T.EditParams<GridSpec>) => {100 return (101 <NumberInput.Root102 className="border-ln-gray-30 flex h-full w-full items-center rounded border"103 value={`${editValue}`}104 onValueChange={(d) => {105 changeValue(Number.isNaN(d.valueAsNumber) ? 0 : d.valueAsNumber);106 }}107 onKeyDown={(e) => {108 if (e.key === "," || e.key === "-") {109 e.preventDefault();110 e.stopPropagation();111 }112 }}113 min={0}114 allowOverflow={false}115 >116 <NumberInput.Input className="w-[calc(100%-16px)] flex-1 focus:outline-none" />117 <NumberInput.Control className="flex w-4 flex-col">118 <NumberInput.IncrementTrigger>119 <ChevronUpIcon />120 </NumberInput.IncrementTrigger>121 <NumberInput.DecrementTrigger>122 <ChevronDownIcon />123 </NumberInput.DecrementTrigger>124 </NumberInput.Control>125 </NumberInput.Root>126 );127};128
129export const TextEditor = ({ editValue, changeValue }: Grid.T.EditParams<GridSpec>) => {130 return (131 <input132 className="border-ln-gray-30 h-full w-full rounded border"133 value={`${editValue}`}134 onChange={(e) => changeValue(e.target.value)}135 />136 );137};1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import type { SalaryData } from "./data";3import { data as rawData } from "./data.js";4
5const sleep = (n = 600) => new Promise((res) => setTimeout(res, n));6
7export async function handleUpdate(updates: Map<string, SalaryData>, resetKey: string) {8 await sleep(200);9
10 const data = dataMaps[resetKey]!;11
12 updates.forEach((x, id) => {13 const row = data.find((c) => c.id === id);14 if (!row) return;15
16 Object.assign(row, x);17 });18}19
20let dataMaps: Record<string, SalaryData[]> = {};21
22export async function Server(reqs: DataRequest[], resetKey: string) {23 if (!dataMaps[resetKey]) {24 dataMaps = { [resetKey]: structuredClone(rawData) };25 }26 const data = dataMaps[resetKey];27 // Simulate latency and server work.28 await sleep();29
30 return reqs.map((c) => {31 return {32 asOfTime: Date.now(),33 data: data.slice(c.start, c.end).map((x) => {34 return {35 kind: "leaf",36 id: x.id,37 data: x,38 };39 }),40 start: c.start,41 end: c.end,42 kind: "center",43 path: c.path,44 size: data.length,45 } satisfies DataResponse;46 });47}The example implements a basic update handler. In most cases, your update logic should look similar to the following:
1onRowDataChange: async ({ rows }) => {2 const updates = new Map([...rows.entries()].map(([key, row]) => [key.id, row]));3
4 await handleUpdate(updates, resetKey);5};Enabling Optimistic Updates
To apply optimistic updates, enable rowUpdateOptimistically on the server data source.
This applies edits on the client before the server responds. Since this setting assumes the server
update will succeed, the change remains client-side until you refresh from the server.
You must still use the onRowDataChange callback to send the update to the server and handle errors.
In the demo below, double-click any cell to edit it. The cell value updates when you commit the change.
Asynchronous Optimistic Editing
1"use client";2
3import "@1771technologies/lytenyte-pro/light-dark.css";4import { useMemo, useState } from "react";5import { handleUpdate, Server } from "./server.jsx";6import { type SalaryData } from "./data.js";7import {8 AgeCellRenderer,9 BaseCellRenderer,10 NumberEditor,11 NumberEditorInteger,12 SalaryRenderer,13 TextEditor,14 YearsOfExperienceRenderer,15} from "./components.jsx";16import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";17
18export interface GridSpec {19 readonly data: SalaryData;20}21
22const columns: Grid.Column<GridSpec>[] = [23 {24 id: "Gender",25 width: 120,26 widthFlex: 1,27 cellRenderer: BaseCellRenderer,28 editRenderer: TextEditor,29 editable: true,30 },31 {32 id: "Education Level",33 name: "Education",34 width: 160,35 widthFlex: 1,36 cellRenderer: BaseCellRenderer,37 editRenderer: TextEditor,38 editable: true,39 },40 {41 id: "Age",42 type: "number",43 width: 100,44 widthFlex: 1,45 cellRenderer: AgeCellRenderer,46 editRenderer: NumberEditorInteger,47 editable: true,48 },49 {50 id: "Years of Experience",51 name: "YoE",52 type: "number",53 width: 100,54 widthFlex: 1,55 cellRenderer: YearsOfExperienceRenderer,56 editRenderer: NumberEditorInteger,57 editable: true,58 },59 {60 id: "Salary",61 type: "number",62 width: 160,63 widthFlex: 1,64 cellRenderer: SalaryRenderer,65 editRenderer: NumberEditor,66 editable: true,67 },68];69
70export default function ServerDataDemo() {71 // No relevant to demo. Used to reset server data.72 const [resetKey] = useState(() => `${Math.random()}`);73
74 const ds = useServerDataSource<SalaryData>({75 queryFn: (params) => {76 return Server(params.requests, resetKey);77 },78 queryKey: [],79 rowUpdateOptimistically: true,80 onRowDataChange: async ({ rows }) => {81 const updates = new Map([...rows.entries()].map(([key, row]) => [key.id, row]));82
83 await handleUpdate(updates, resetKey);84 ds.refresh();85 },86 blockSize: 50,87 });88
89 const isLoading = ds.isLoading.useValue();90
91 return (92 <div className="ln-grid" style={{ height: 500 }}>93 <Grid94 rowSource={ds}95 editMode="cell"96 editClickActivator="single-click"97 columns={columns}98 styles={useMemo(() => {99 return { viewport: { style: { scrollbarGutter: "stable" } } };100 }, [])}101 slotViewportOverlay={102 isLoading && (103 <div className="bg-ln-gray-20/40 absolute left-0 top-0 z-20 h-full w-full animate-pulse"></div>104 )105 }106 />107 </div>108 );109}1import { NumberInput } from "@ark-ui/react/number-input";2import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons";3import type { Grid } from "@1771technologies/lytenyte-pro";4import type { GridSpec } from "./demo";5
6function SkeletonLoading() {7 return (8 <div className="h-full w-full p-2">9 <div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div>10 </div>11 );12}13
14const formatter = new Intl.NumberFormat("en-Us", {15 minimumFractionDigits: 0,16 maximumFractionDigits: 0,17});18export function SalaryRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {19 const field = api.columnField(column, row);20
21 if (api.rowIsLeaf(row) && !row.data && row.loading) return <SkeletonLoading />;22
23 if (typeof field !== "number") return null;24
25 return <div className="flex h-full w-full items-center justify-end">${formatter.format(field)}</div>;26}27
28export function YearsOfExperienceRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {29 const field = api.columnField(column, row);30
31 if (api.rowIsLeaf(row) && !row.data && row.loading) return <SkeletonLoading />;32
33 if (typeof field !== "number") return null;34
35 return (36 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">37 <span className="font-bold">{field}</span>{" "}38 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>39 </div>40 );41}42
43export function AgeCellRenderer({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {44 const field = api.columnField(column, row);45
46 if (api.rowIsLeaf(row) && !row.data && row.loading) return <SkeletonLoading />;47
48 if (typeof field !== "number") return null;49
50 return (51 <div className="flex w-full items-baseline justify-end gap-1 tabular-nums">52 <span className="font-bold">{formatter.format(field)}</span>{" "}53 <span className="text-xs">{field <= 1 ? "Year" : "Years"}</span>54 </div>55 );56}57
58export function BaseCellRenderer({ row, column, api }: Grid.T.CellRendererParams<GridSpec>) {59 if (api.rowIsLeaf(row) && !row.data && row.loading) return <SkeletonLoading />;60
61 const field = api.columnField(column, row);62
63 return <div className="flex h-full w-full items-center">{field as string}</div>;64}65
66export const NumberEditorInteger = ({ editValue, changeValue }: Grid.T.EditParams<GridSpec>) => {67 return (68 <NumberInput.Root69 className="border-ln-gray-30 flex h-full w-full items-center rounded border"70 value={`${editValue}`}71 onValueChange={(d) => {72 changeValue(Number.isNaN(d.valueAsNumber) ? 0 : d.valueAsNumber);73 }}74 min={0}75 max={100}76 allowOverflow={false}77 >78 <NumberInput.Input79 className="w-[calc(100%-16px)] flex-1 focus:outline-none"80 onKeyDown={(e) => {81 if (e.key === "." || e.key === "," || e.key === "-") {82 e.preventDefault();83 e.stopPropagation();84 }85 }}86 />87 <NumberInput.Control className="flex w-4 flex-col">88 <NumberInput.IncrementTrigger>89 <ChevronUpIcon />90 </NumberInput.IncrementTrigger>91 <NumberInput.DecrementTrigger>92 <ChevronDownIcon />93 </NumberInput.DecrementTrigger>94 </NumberInput.Control>95 </NumberInput.Root>96 );97};98
99export const NumberEditor = ({ editValue, changeValue }: Grid.T.EditParams<GridSpec>) => {100 return (101 <NumberInput.Root102 className="border-ln-gray-30 flex h-full w-full items-center rounded border"103 value={`${editValue}`}104 onValueChange={(d) => {105 changeValue(Number.isNaN(d.valueAsNumber) ? 0 : d.valueAsNumber);106 }}107 onKeyDown={(e) => {108 if (e.key === "," || e.key === "-") {109 e.preventDefault();110 e.stopPropagation();111 }112 }}113 min={0}114 allowOverflow={false}115 >116 <NumberInput.Input className="w-[calc(100%-16px)] flex-1 focus:outline-none" />117 <NumberInput.Control className="flex w-4 flex-col">118 <NumberInput.IncrementTrigger>119 <ChevronUpIcon />120 </NumberInput.IncrementTrigger>121 <NumberInput.DecrementTrigger>122 <ChevronDownIcon />123 </NumberInput.DecrementTrigger>124 </NumberInput.Control>125 </NumberInput.Root>126 );127};128
129export const TextEditor = ({ editValue, changeValue }: Grid.T.EditParams<GridSpec>) => {130 return (131 <input132 className="border-ln-gray-30 h-full w-full rounded border"133 value={`${editValue}`}134 onChange={(e) => changeValue(e.target.value)}135 />136 );137};1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import type { SalaryData } from "./data";3import { data as rawData } from "./data.js";4
5const sleep = (n = 600) => new Promise((res) => setTimeout(res, n));6
7export async function handleUpdate(updates: Map<string, SalaryData>, resetKey: string) {8 await sleep(200);9
10 const data = dataMaps[resetKey]!;11
12 updates.forEach((x, id) => {13 const row = data.find((c) => c.id === id);14 if (!row) return;15
16 Object.assign(row, x);17 });18}19
20let dataMaps: Record<string, SalaryData[]> = {};21
22export async function Server(reqs: DataRequest[], resetKey: string) {23 if (!dataMaps[resetKey]) {24 dataMaps = { [resetKey]: structuredClone(rawData) };25 }26 const data = dataMaps[resetKey];27 // Simulate latency and server work.28 await sleep();29
30 return reqs.map((c) => {31 return {32 asOfTime: Date.now(),33 data: data.slice(c.start, c.end).map((x) => {34 return {35 kind: "leaf",36 id: x.id,37 data: x,38 };39 }),40 start: c.start,41 end: c.end,42 kind: "center",43 path: c.path,44 size: data.length,45 } satisfies DataResponse;46 });47}When optimistic updates are enabled, you can skip calling refresh when the optimistic
value matches the expected server result. The server update logic follows this pattern:
1onRowDataChange: async ({ rows }) => {2 const updates = new Map([...rows.entries()].map(([key, row]) => [key.id, row]));3
4 await handleUpdate(updates, resetKey);5},6rowUpdateOptimistically: true,Next Steps
- Server Row Data: Slice and load rows from the server.
- Server Row Grouping: Use the server data source to load and manage group slices.
- Optimistic Loading: Pre-fetch data using optimistic loading to reduce perceived latency.
