# Example: Async cell editing

One editor per column.type — text, number, boolean, enum, and date — committing through an 800ms onRowChange that rejects a negative quantity so you can watch the saving and error phases.

Source: https://pretable.ai/examples/async-cell-editing.md

```tsx AsyncEditingGrid.tsx
"use client";

import { useState } from "react";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { type StockItem, stockItems } from "./data";

const VIEWPORT_HEIGHT = 220;
// Every commit — success or rejection — pays this delay, so the field
// visibly sits in `saving` (dimmed, aria-busy) no matter which column you
// edit, not just the one that ends up rejected.
const COMMIT_DELAY_MS = 800;

const delay = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

export function AsyncEditingGrid() {
  const [rows, setRows] = useState<StockItem[]>(stockItems);
  const [status, setStatus] = useState(
    "Idle — commits take about 800ms, so you can watch a cell save.",
  );

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Edit <strong>Quantity</strong> to a negative number to see the rejection
        path: the field sits in <code>saving</code> for ~800ms, then{" "}
        <code>onRowChange</code> rejects it — an inline error appears, the
        editor stays open, and <kbd>Enter</kbd> retries.
      </p>
      <PretableSurface
        ariaLabel="Stock items"
        columns={columns}
        getRowId={(row) => row.id}
        rows={rows}
        viewportHeight={VIEWPORT_HEIGHT}
        onRowChange={async ({ rowId, columnId, value, row }) => {
          setStatus(`Saving ${columnId}…`);
          await delay(COMMIT_DELAY_MS);
          if (
            columnId === "quantity" &&
            typeof value === "number" &&
            value < 0
          ) {
            setStatus(`Rejected: quantity can't go negative`);
            throw new Error("Quantity can't go negative");
          }
          setRows((previous) =>
            previous.map((candidate) =>
              candidate.id === rowId ? row : candidate,
            ),
          );
          setStatus(`Saved ${columnId} on ${rowId}`);
        }}
      />
      <p style={{ margin: "8px 0 0", fontSize: 13 }}>
        <code>{status}</code>
      </p>
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { StockItem } from "./data";

export const columns: PretableColumn<StockItem>[] = [
  { id: "item", header: "Item", editable: true, widthPx: 150 },
  {
    id: "quantity",
    header: "Quantity",
    type: "number",
    editable: true,
    widthPx: 90,
    // Built-in parsing already turned an empty draft into `null` and
    // rejected a non-numeric draft ("Not a number") by the time this runs —
    // this is for a domain rule, not parsing. The *negative* case is
    // deliberately left for onRowChange below, so it shows the async
    // `saving` → `error` phases instead of the synchronous `validating`
    // bounce this fractional check demonstrates.
    validate: (value) => {
      if (value === null) return true;
      if (typeof value === "number" && !Number.isInteger(value)) {
        return "Quantity must be a whole number";
      }
      return true;
    },
  },
  {
    id: "inStock",
    header: "In stock",
    type: "boolean",
    editable: true,
    widthPx: 80,
  },
  {
    id: "priority",
    header: "Priority",
    type: "enum",
    editable: true,
    widthPx: 100,
    options: [
      { value: "low", label: "Low" },
      { value: "medium", label: "Medium" },
      { value: "high", label: "High" },
    ],
  },
  {
    id: "restockBy",
    header: "Restock by",
    type: "date",
    editable: true,
    widthPx: 110,
  },
];
```

```ts data.ts
export interface StockItem {
  id: string;
  item: string;
  quantity: number;
  inStock: boolean;
  priority: "low" | "medium" | "high";
  restockBy: string;
}

export const stockItems: StockItem[] = [
  {
    id: "s1",
    item: "Air filters",
    quantity: 24,
    inStock: true,
    priority: "high",
    restockBy: "2026-08-18",
  },
  {
    id: "s2",
    item: "Packing tape",
    quantity: 6,
    inStock: false,
    priority: "medium",
    restockBy: "2026-08-22",
  },
  {
    id: "s3",
    item: "Shipping labels",
    quantity: 120,
    inStock: true,
    priority: "low",
    restockBy: "2026-08-25",
  },
  {
    id: "s4",
    item: "Barcode scanners",
    quantity: 3,
    inStock: true,
    priority: "medium",
    restockBy: "2026-09-01",
  },
  {
    id: "s5",
    item: "Safety gloves",
    quantity: 40,
    inStock: false,
    priority: "high",
    restockBy: "2026-09-05",
  },
];
```
