f-ui
Components

Editable Table

Edit a Formily ArrayField as a data table — quick start, column recipes, troubleshooting, row or inline editing, validation feedback, and real-world order-entry patterns.

Plus Registry

Editable Table ships on the authenticated @f-ui-plus registry. Configure FUI_PLUS_REGISTRY_TOKEN as described in Installation — Plus Registry.

Renamed

Editable Table is the public name for what was Form Array Table. Imports: EditableTable / useEditableTable / defineEditableColumns. The frozen form-array-table package and its registry alias have been removed — migrate remaining installs to @f-ui-plus/editable-table.

Editable Table edits a Formily ArrayField as a table inside a parent Form — line items, tasks, approval rows. Array values live in form.values; table chrome reuses shared table primitives bundled with this component. Use it when rows are part of one submit; for server-driven browse lists, use Table instead.

How to read this page

Quick StartExamples (Level 1 in order) → Define Your ColumnsCore ConceptsRecipes / Troubleshooting.

When to Use

ScenarioWhy Editable Table
Repeating field groups in one parent formEach row is a slice of form.values; submit validates the whole form once.
Per-row commit with Save / CancelRow mode keeps drafts out of form.values until the user explicitly saves.
Always-live cells in a draft applicationInline mode binds cells straight to form.values — no per-row Save gate.
Cross-row rules after commitTotals, freight policy, and header reactions run on the committed array (parent effects).
Dense grids with paging or virtual scrollfeatures.view slices or virtualizes the view while submit validation still scans every row.
Wide line-item rows (many columns, stay in the grid)Pin identity columns + horizontal scroll + column manager — see Wide Columns: Flat Matrix.
Wide rows where secondary fields can leave the matrixColumn tier: "secondary" + expandable row detail — see Wide Rows: Column Tiers & Row Detail.
Parent rows that own child entities (claims, splits, sub-lines)Master–detail nested — see Nested Sub-Rows (Master–Detail).
Line items inside a FormPageeditMode="inline" + variant="embedded" — always-live cells, grid chrome off, submit validates the whole form.

When Not to Use

ScenarioPrefer instead
Server-driven list with JSON Logic filters and URL syncTable + list adapter
Single scalar field or a short static listArrayField + ObjectField rows in Form
Spreadsheet-style browse with occasional cell editTable + row actions
Records edited only on a separate detail routeA dedicated page — stay-on-the-page editing is this component's thesis

Installing

Editable Table is a Plus component — install through the authenticated @f-ui-plus registry with FUI_PLUS_REGISTRY_TOKEN configured.

pnpm dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/editable-table.json
npx shadcn@latest add https://ui.isaacfei.com/api/plus/r/editable-table.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/api/plus/r/editable-table.json
bunx shadcn@latest add https://ui.isaacfei.com/api/plus/r/editable-table.json

With the @f-ui-plus namespace registered in components.json, shadcn add @f-ui-plus/editable-table also works and pulls formily automatically.

Usage

Wrap the table in <Form> under an ArrayField path. Define columns once with defineEditableColumns — display kind for read formatting, optional field for edit wiring.

import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";

const columns = defineEditableColumns({
  sku: { kind: "text", label: "SKU", field: { required: true } },
  qty: { kind: "number", label: "Qty", field: true },
});

<EditableTable
  name="lineItems"
  columns={columns}
  getRowId={(row) => row.id}
  recordCreator={{ record: () => ({ id: crypto.randomUUID(), qty: 1 }) }}
/>

field: true (or field: { required: true } without component) infers the edit control from kindnumber → connected NumberInput, textInput, currencyCurrencyInput. Do not hand-wire Input + type: "number". In row mode (default), click Edit on a row to change values; Save commits into form.values. Cancel discards the draft. Submit the parent form to persist everything. In inline mode, cells are always live — set editMode="inline" and skip per-row Save.

Quick Start (5 Minutes)

Follow this checklist the first time you wire a table into a form:

  1. Install the Plus registry item (see Installing) — it pulls formily automatically.
  2. Wrap your page in <Form form={form} onSubmit={…}>.
  3. Define columns once with defineEditableColumns — see Define Your Columns.
  4. Pass name (the ArrayField path), columns, and getRowId (stable id per row — required for selection, duplicate, and error jump links).
  5. Choose edit mode: editMode="inline" + variant="embedded" for the common case — line items inside a form, always-live cells, no per-row Save. Use row mode (below) only for remote per-row persistence.
  6. Add rows with recordCreator.record — new rows open in edit mode until Save (row mode) or append live (inline).
  7. Submit the parent <Form> — validation runs over every row in form.values, including rows hidden by search or on another page.

Minimal copy-paste:

const form = createForm<{ lines: Line[] }>({
  initialValues: { lines: [{ id: "1", sku: "A", qty: 1 }] },
});

<Form form={form} onSubmit={async (values) => api.save(values)}>
  <EditableTable
    name="lines"
    variant="embedded"
    editMode="inline"
    columns={columns}
    getRowId={(row) => row.id}
    recordCreator={{ record: () => ({ id: crypto.randomUUID(), qty: 1 }) }}
  />
  <FormActions><Button type="submit">Save order</Button></FormActions>
</Form>

Continue with Row Mode — Operational Grid.

Examples

Demos and source code

Each example renders a live preview with a Code tab — that tab is the full demo source from src/demos/editable-table/. Prose on this page explains what to try; the code tab is what you copy into your app.

Learning path

Start at Level 1 in order. Open Complete Order Entry only after Cell Validation Feedback and Cross-Row Header Effects — it combines those patterns.

Level 1 — Get It Working

Row Mode — Operational Grid

Problem: Each row is an independently-committed draft in an operational grid (search, paging, batch actions) — not just a slice of one form submit.

This demo proves: Default row mode keeps drafts out of form.values until Save commits a row. This is the operational-grid case — for line items in a form, prefer inline + embedded (see Quick Start). For per-row server onSave / onDelete, see Remote Persistence.

Try it:

  1. Edit a row, change qty, Save — then submit the form.
  2. Search tape — the view filters; array order stays the same until Save.
  3. Add from catalog — bulk append via the toolbar.
#
Actions
1SKU-001Wrench219.9039.80
2SKU-010Socket set189.0089.00
3SKU-020Drill bit412.5050.00
4SKU-030Tape measure38.7526.25
Sum205.05
4 rows
Rows per page
"use client";

import { useCallback, useMemo, useState } from "react";
import { isArrayField } from "@formily/core";
import { toast } from "sonner";

import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

import {
  BASIC_DEMO_SEED,
  CATALOG_LINES,
  lineColumns,
  type LineItem,
} from "./editable-table-demo-shared";

type OrderValues = { orderNo: string; lineItems: LineItem[] };

const getRowId = (r: LineItem) => r.id;

export function EditableTableDemo() {
  const orderForm = useMemo(
    () =>
      createForm<OrderValues>({
        initialValues: { orderNo: "SO-0042", lineItems: BASIC_DEMO_SEED },
      }),
    [],
  );
  const [savedOrder, setSavedOrder] = useState<OrderValues | null>(null);

  const addFromCatalog = useCallback(() => {
    const field = orderForm.query("lineItems").take();
    if (!field || !isArrayField(field)) return;
    for (const line of CATALOG_LINES) {
      field.push({ id: crypto.randomUUID(), ...line });
    }
  }, [orderForm]);

  return (
    <Form
      form={orderForm}
      onSubmit={async (v) => {
        setSavedOrder(v);
        toast.success("Submitted successfully");
      }}
    >
      <FormField name="orderNo" label="Order #" component={[Input]} />
      <EditableTable<LineItem>
        name="lineItems"
        tableCode="demo-order-lines"
        columns={lineColumns}
        getRowId={getRowId}
        recordCreator={{
          record: () => ({ id: crypto.randomUUID(), qty: 1, price: 0 }),
        }}
        toolbar={{
          trailing: (
            <Button type="button" variant="outline" onClick={addFromCatalog}>
              Add from catalog
            </Button>
          ),
        }}
        features={{
          index: { mode: "view" },
          view: { mode: "offset", pageSize: 5 },
          sorting: true,
          search: true,
          selection: { batchDelete: true },
          columns: { manager: true },
          footer: true,
        }}
      />
      <FormActions>
        <Button type="submit">
          Save order
        </Button>
      </FormActions>
      {savedOrder ? (
        <pre className="bg-muted mt-3 rounded-md p-3 text-xs">
          <code>{JSON.stringify(savedOrder, null, 2)}</code>
        </pre>
      ) : null}
    </Form>
  );
}

Single Edit Session

Problem: Only one row should be editable at a time.

This demo proves: editable.type: "single" blocks a second edit and submit until Save/Cancel. Look for editable={{ type: "single" }} in the Code tab.

Try it:

  1. Edit row 1, then try to edit row 2 — blocked.
  2. Submit while row 1 is still editing — blocked.
  3. Save row 1 — then you can edit row 2.
Actions
Wireframe4
Build8
"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type Task = { id: string; title: string; hours: number };

const columns = defineEditableColumns<Task>({
  title: {
    ...f.text({ label: "Task" }),
    field: { component: [Input], required: true },
  },
  hours: {
    ...f.number({
      label: "Hours",
    }),
    size: 100,
    field: {
      component: [NumberInput, { surface: "tableCell" }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
    },
  },
});

export function EditableTablePendingEditDemo() {
  const form = useMemo(
    () =>
      createForm<{ tasks: Task[] }>({
        initialValues: {
          tasks: [
            { id: "t1", title: "Wireframe", hours: 4 },
            { id: "t2", title: "Build", hours: 8 },
          ],
        },
      }),
    [],
  );

  return (
    <Form
      form={form}
      onSubmit={async () => {
        toast.success("Submitted");
      }}
    >
      <EditableTable<Task>
        name="tasks"
        columns={columns}
        getRowId={(r) => r.id}
        editable={{ type: "single" }}
        recordCreator={{
          record: () => ({ id: crypto.randomUUID(), hours: 1 }),
        }}
      />
      <FormActions>
        <Button type="submit">
          Submit
        </Button>
      </FormActions>
    </Form>
  );
}

Inline Edit Mode

Problem: The table is one section of a large draft form, not an operational grid.

This demo proves: editMode="inline" binds cells straight to form.values; Save draft persists without form.validate().

variant="embedded" turns off grid affordances (search, sort, batch-delete, column manager). Opt back in per-feature via features (e.g. features={{ search: true }}). Summary footer is opt-in (features.footer) — leave it off on wide scroll matrices.

Try it:

  1. Leave a field empty and click Save draft — no validation.
  2. Click Submit with the same empty field — validation blocks.
  3. Compare with Row Mode — Operational Grid — row mode needs Save per row.
#
Item
Qty
Actions
1
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type DraftLine = { id: string; item: string; qty: number };

const columns = defineEditableColumns<DraftLine>({
  item: {
    ...f.text({ label: "Item" }),
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({
      label: "Qty",
    }),
    size: 100,
    field: {
      component: [NumberInput, { surface: "tableCell" }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
    },
    footer: (rows) => rows.reduce((s, r) => s + Number(r.qty || 0), 0),
  },
});

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

export function EditableTableDraftApplicationDemo() {
  const [savingDraft, setSavingDraft] = useState(false);

  const form = useMemo(
    () =>
      createForm<{ lines: DraftLine[] }>({
        initialValues: {
          lines: [{ id: "d1", item: "", qty: 1 }],
        },
      }),
    [],
  );

  const saveDraft = async () => {
    setSavingDraft(true);
    await delay(600);
    setSavingDraft(false);
    toast.success(
      `Draft saved (${form.values.lines?.length ?? 0} lines, no validation)`,
    );
  };

  const onSubmit = async (values: { lines: DraftLine[] }) => {
    await delay(400);
    toast.success(`Submitted ${values.lines.length} lines`);
  };

  return (
    <Form form={form} onSubmit={onSubmit}>
      <EditableTable<DraftLine>
        name="lines"
        tableCode="demo-draft-lines"
        columns={columns}
        editMode="inline"
        variant="embedded"
        features={{
          index: { mode: "view" },
        }}
        getRowId={(r) => r.id}
        recordCreator={{
          record: () => ({ id: crypto.randomUUID(), item: "", qty: 1 }),
        }}
      />
      <FormActions>
        <Button
          type="button"
          variant="outline"
          disabled={savingDraft}
          onClick={saveDraft}
        >
          {savingDraft ? "Saving…" : "Save draft"}
        </Button>
        <Button type="submit">
          Submit
        </Button>
      </FormActions>
    </Form>
  );
}

Typed Field Columns

Problem: Read formatting and edit controls must not drift (currency, status tags, booleans).

This demo proves: kind formats read and edit the same way; field: true infers the control. Compare plan, fee, and active column definitions in the Code tab.

Try it:

  1. Toggle Row vs Inline — same column kind in both modes.
  2. Edit Plan — status tag in read matches the select in edit.
  3. See field: true on fee and active columns in the Code tab.
Edit mode
Actions
Ada LovelacePro49.00True
Alan TuringEnterprise199.00False
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type Member = {
  id: string;
  name: string;
  plan: "trial" | "pro" | "enterprise";
  fee: number;
  active: boolean;
};

const PLAN_VARIANTS = {
  trial: { label: "Trial", tone: "warning" },
  pro: { label: "Pro", tone: "info" },
  enterprise: { label: "Enterprise", tone: "success" },
};

const columns = defineEditableColumns<Member>({
  name: { ...f.text({ label: "Name" }), size: 150, field: { required: true } },
  plan: {
    ...f.enum({ render: "status", label: "Plan", variants: PLAN_VARIANTS }),
    size: 130,
    field: { required: true },
  },
  fee: {
    ...f.currency({ label: "Monthly fee", currency: "USD", measure: "none" }),
    size: 130,
    field: true,
  },
  active: { ...f.boolean({ label: "Active" }), size: 90, field: true },
});

const SEED: Member[] = [
  { id: "m1", name: "Ada Lovelace", plan: "pro", fee: 49, active: true },
  {
    id: "m2",
    name: "Alan Turing",
    plan: "enterprise",
    fee: 199,
    active: false,
  },
];

export function EditableTableTypedFieldsDemo() {
  const [mode, setMode] = useState<"row" | "inline">("row");

  const form = useMemo(
    () =>
      createForm<{ members: Member[] }>({ initialValues: { members: SEED } }),
    [],
  );

  return (
    <>
      <div className="mb-3 flex items-center gap-2">
        <span className="text-muted-foreground text-sm">Edit mode</span>
        <div className="inline-flex gap-1">
          <Button
            type="button"
            variant={mode === "row" ? "default" : "outline"}
            onClick={() => setMode("row")}
          >
            Row
          </Button>
          <Button
            type="button"
            variant={mode === "inline" ? "default" : "outline"}
            onClick={() => setMode("inline")}
          >
            Inline
          </Button>
        </div>
      </div>
      <Form
        form={form}
        density="compact"
        onSubmit={async (v) => {
          toast.success(`Submitted ${v.members.length} members`);
        }}
      >
        <EditableTable<Member>
          key={mode}
          name="members"
          editMode={mode}
          tableCode="demo-typed-fields"
          columns={columns}
          getRowId={(r) => r.id}
          recordCreator={{
            record: () => ({
              id: crypto.randomUUID(),
              name: "",
              plan: "trial",
              fee: 0,
              active: false,
            }),
          }}
          features={{ sorting: true }}
        />
        <FormActions>
          <Button type="submit">
            Submit
          </Button>
        </FormActions>
      </Form>
    </>
  );
}

Wide Rows: Column Tiers & Row Detail

Problem: Line-item rows exceed comfortable scan width (10+ columns) — horizontal scroll hides critical fields.

This demo proves: Mark low-frequency columns with tier: "secondary" so they render in an expandable row-detail panel instead of the matrix. Primary columns stay in the table (~7 or fewer for scan). Expand/collapse uses the shared table __expand column (buildExpandColumn from Table); Editable Table adds EditableRowDetail for secondary fields and toolbar Expand all / Collapse all via expand={{ expandAll: true }}.

Try it:

  1. Scan the primary grid — SKU, qty, price stay visible without horizontal scroll.
  2. Click a row chevron — secondary fields (tax code, notes, …) open in the detail panel beneath the row.
  3. Use Expand all in the toolbar — every row opens; Collapse all closes them.
  4. Edit a secondary field inline — panel inputs use the same Formily wiring as primary cells.

Virtual row-detail rendering is intentionally deferred in v1 — keep tiered-detail workflows on view.mode: "all" or view.mode: "offset".

For child entities under a parent row (not extra fields of the same entity), use Nested Sub-Rows (Master–Detail) instead — nested and tier: "secondary" cannot share one chevron.

#
SKU
Item
Qty
Unit price
Status
Actions
1
Warehouse
Requested by
Needed by
7122026
Tax code
Notes
2
Warehouse
Requested by
Needed by
7192026
Tax code
Notes
Sum
"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type WorkOrderLine = {
  id: string;
  sku: string;
  item: string;
  qty: number;
  unitPrice: number;
  status: "draft" | "review" | "approved";
  warehouse: string;
  requestedBy: string;
  neededBy: Date | null;
  taxCode: string;
  notes: string;
};

const STATUS_VARIANTS = {
  draft: { label: "Draft", tone: "warning" },
  review: { label: "Review", tone: "info" },
  approved: { label: "Approved", tone: "success" },
};

const columns = defineEditableColumns<WorkOrderLine>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 130,
    field: { component: [Input], required: true },
  },
  item: {
    ...f.text({ label: "Item" }),
    size: 180,
    field: { component: [Input], required: true },
  },
  qty: { ...f.number({ label: "Qty" }), size: 90, field: true },
  unitPrice: {
    ...f.currency({ label: "Unit price", currency: "USD", measure: "none" }),
    size: 120,
    field: true,
  },
  status: {
    ...f.enum({ render: "status", label: "Status", variants: STATUS_VARIANTS }),
    size: 130,
    field: { required: true },
  },
  warehouse: {
    ...f.text({ label: "Warehouse" }),
    tier: "secondary",
    field: { component: [Input], required: true },
  },
  requestedBy: {
    ...f.text({ label: "Requested by" }),
    tier: "secondary",
    field: { component: [Input], required: true },
  },
  neededBy: {
    ...f.date({ label: "Needed by" }),
    tier: "secondary",
    field: true,
  },
  taxCode: {
    ...f.text({ label: "Tax code" }),
    tier: "secondary",
    field: { component: [Input] },
  },
  notes: {
    ...f.text({ label: "Notes" }),
    tier: "secondary",
    field: { component: [Input] },
  },
});

const SEED: WorkOrderLine[] = [
  {
    id: "w1",
    sku: "A-100",
    item: "Motor controller",
    qty: 3,
    unitPrice: 249.5,
    status: "review",
    warehouse: "SEA-1",
    requestedBy: "Ivy Chen",
    neededBy: new Date(2026, 6, 12),
    taxCode: "TX-01",
    notes: "Confirm firmware rev before ship",
  },
  {
    id: "w2",
    sku: "B-220",
    item: "Harness kit",
    qty: 8,
    unitPrice: 74.0,
    status: "draft",
    warehouse: "SFO-2",
    requestedBy: "Jae Park",
    neededBy: new Date(2026, 6, 19),
    taxCode: "",
    notes: "",
  },
];

export function EditableTableColumnTiersDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: WorkOrderLine[] }>({
        initialValues: { lines: SEED },
      }),
    [],
  );

  return (
    <Form
      form={form}
      density="compact"
      onSubmit={async (values) => {
        toast.success(`Submitted ${values.lines.length} lines`);
      }}
    >
      <EditableTable<WorkOrderLine>
        name="lines"
        variant="embedded"
        editMode="inline"
        tableCode="demo-column-tiers"
        columns={columns}
        getRowId={(row) => row.id}
        expand={{ expandAll: true, defaultExpanded: true }}
        recordCreator={{
          record: () => ({
            id: crypto.randomUUID(),
            sku: "",
            item: "",
            qty: 1,
            unitPrice: 0,
            status: "draft",
            warehouse: "SEA-1",
            requestedBy: "",
            neededBy: null,
            taxCode: "",
            notes: "",
          }),
        }}
        features={{ index: { mode: "view" }, footer: true }}
      />
      <FormActions>
        <Button type="submit">
          Submit
        </Button>
      </FormActions>
    </Form>
  );
}

Wide Columns: Flat Matrix (Scroll + Pin)

Problem: Real line items often keep all fields in the grid (14+ columns) — no expand, no secondary panel. Operators compare across rows while scrolling horizontally. Toy demos with 4–7 columns never surface pin / scroll / ComboBox density.

This demo proves: Fourteen columns stay in the matrix. SKU declares pinned: "left" (identity stays put while you scroll); Actions stay right-pinned. ComboBox / Select cells use short code labels so the frozen SKU column stays readable. Column manager can hide or re-pin. Picking a SKU still carries out item / price / UoM / tax.

Try it:

  1. Note SKU stays visible while you scroll the table horizontally past Warehouse → Notes.
  2. Open a SKU ComboBox — pick BRG-55; Item and Unit price update; cell still shows the short code BRG-55.
  3. Edit Warehouse / Requester / Cost ctr ComboBoxes mid-scroll — popovers clear the table chrome.
  4. Columns → hide Notes or pin Warehouse left; Reset restores author pins.
#
SKU
Item
Qty
Unit price
Disc %
Line total
Status
UoM
Warehouse
Tax
Batch
Needed by
Requester
Ship via
Cost ctr
Notes
Actions
1
748.50
EA
TX-01
7122026
2
562.40
KIT
TX-01
7192026
3
248.00
EA
TX-02
812026
4
136.00
KIT
TX-02
812026
5
738.00
EA
TX-01
7282026
6
273.75
EA
TX-03
882026
7
132.00
EA
TX-01
8152026
8
126.00
RL
TX-03
7302026
"use client";

import { useMemo } from "react";
import { toast } from "sonner";
import { isField, onFieldInputValueChange } from "@formily/core";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { ComboBox } from "@/components/f-ui/formily/connects/combo-box";
import { CurrencyInput } from "@/components/f-ui/formily/connects/currency-input";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

/**
 * Flat wide matrix — 14 columns, no expand / no secondary tier.
 * Identity (SKU) pins left; Actions pin right; everything else scrolls.
 * ComboBox labels stay short so cells stay scannable under horizontal scroll.
 */
type ProcurementLine = {
  id: string;
  sku: string;
  item: string;
  qty: number;
  unitPrice: number;
  discountPct: number;
  lineTotal: number;
  status: "draft" | "review" | "approved";
  uom: string;
  warehouse: string;
  taxCode: string;
  batchNo: string;
  neededBy: Date | null;
  requestedBy: string;
  shipVia: string;
  costCenter: string;
  notes: string;
};

const STATUS_VARIANTS = {
  draft: { label: "Draft", tone: "warning" },
  review: { label: "Review", tone: "info" },
  approved: { label: "Approved", tone: "success" },
};

const SKU_MASTER: {
  sku: string;
  item: string;
  unitPrice: number;
  uom: string;
  taxCode: string;
}[] = [
  { sku: "MC-4100", item: "Motor controller rev C", unitPrice: 249.5, uom: "EA", taxCode: "TX-01" },
  { sku: "HK-220", item: "Harness kit 24-pin", unitPrice: 74, uom: "KIT", taxCode: "TX-01" },
  { sku: "BR-90", item: "Bearing race 90mm", unitPrice: 12.4, uom: "EA", taxCode: "TX-02" },
  { sku: "SK-12", item: "Seal kit (matched)", unitPrice: 6.8, uom: "KIT", taxCode: "TX-02" },
  { sku: "PSU-48", item: "48V PSU industrial", unitPrice: 410, uom: "EA", taxCode: "TX-01" },
  { sku: "CAB-5M", item: "Shielded cable 5m", unitPrice: 18.25, uom: "EA", taxCode: "TX-03" },
  { sku: "FAN-120", item: "Cooling fan 120mm", unitPrice: 22, uom: "EA", taxCode: "TX-01" },
  { sku: "LBL-500", item: "Thermal label roll", unitPrice: 31.5, uom: "RL", taxCode: "TX-03" },
  { sku: "BRG-55", item: "Bearing 55mm sealed", unitPrice: 9.9, uom: "EA", taxCode: "TX-02" },
  { sku: "GASK-8", item: "Gasket set M8", unitPrice: 4.25, uom: "KIT", taxCode: "TX-03" },
];

/** Cell labels = codes only; long names live in Item after pick. */
const SKU_OPTIONS = SKU_MASTER.map((row) => ({ value: row.sku, label: row.sku }));

const WAREHOUSE_OPTIONS = [
  { value: "SEA-1", label: "SEA-1" },
  { value: "SFO-2", label: "SFO-2" },
  { value: "DAL-3", label: "DAL-3" },
  { value: "ORD-4", label: "ORD-4" },
];

const SHIP_VIA_OPTIONS = [
  { value: "Ground", label: "Ground" },
  { value: "Air", label: "Air" },
  { value: "Ocean", label: "Ocean" },
  { value: "Will-call", label: "Will-call" },
];

const COST_CENTER_OPTIONS = [
  { value: "OPS-240", label: "OPS-240" },
  { value: "ENG-110", label: "ENG-110" },
  { value: "FAC-050", label: "FAC-050" },
  { value: "QA-030", label: "QA-030" },
];

const REQUESTER_OPTIONS = [
  { value: "Ivy Chen", label: "Ivy Chen" },
  { value: "Jae Park", label: "Jae Park" },
  { value: "Sam Ortiz", label: "Sam Ortiz" },
  { value: "Mina Cho", label: "Mina Cho" },
];

function lineNet(row: Pick<ProcurementLine, "qty" | "unitPrice" | "discountPct">) {
  const gross = Number(row.qty) * Number(row.unitPrice);
  const disc = Number(row.discountPct) || 0;
  return gross * (1 - disc / 100);
}

const columns = defineEditableColumns<ProcurementLine>({
  sku: {
    ...f.text({ label: "SKU", sortKey: "sku" }),
    size: 110,
    minSize: 96,
    pinned: "left",
    field: {
      component: [
        ComboBox,
        { options: SKU_OPTIONS, placeholder: "SKU…", creatable: true },
      ],
      required: true,
    },
  },
  item: {
    ...f.text({ label: "Item", sortKey: "item" }),
    size: 160,
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({ label: "Qty", sortKey: "qty" }),
    size: 72,
    field: {
      component: [NumberInput, { surface: "tableCell" }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "needs > 0"),
    },
  },
  unitPrice: {
    ...f.currency({
      label: "Unit price",
      currency: "USD",
      sortKey: "unitPrice",
      measure: "none",
    }),
    size: 100,
    field: {
      component: [CurrencyInput, { currency: "USD", surface: "tableCell", measure: "none" }],
    },
  },
  discountPct: {
    ...f.number({ label: "Disc %", sortKey: "discountPct" }),
    size: 72,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0, max: 50 }],
    },
  },
  lineTotal: {
    ...f.currency({
      label: "Line total",
      currency: "USD",
      measure: "none",
      accessor: (row) => lineNet(row),
    }),
    size: 100,
    field: {
      component: [Input],
      readPretty: () => true,
      reactions: (field) => {
        const qty = Number(field.query(".qty").value()) || 0;
        const unitPrice = Number(field.query(".unitPrice").value()) || 0;
        const discountPct = Number(field.query(".discountPct").value()) || 0;
        if (isField(field)) {
          field.value = lineNet({ qty, unitPrice, discountPct });
        }
      },
    },
  },
  status: {
    ...f.enum({ render: "status", label: "Status", variants: STATUS_VARIANTS }),
    size: 110,
    field: { required: true },
  },
  uom: {
    ...f.text({ label: "UoM" }),
    size: 64,
    field: {
      component: [Input],
      readPretty: () => true,
    },
  },
  warehouse: {
    ...f.text({ label: "Warehouse" }),
    size: 100,
    field: {
      component: [ComboBox, { options: WAREHOUSE_OPTIONS, placeholder: "Whse…" }],
      required: true,
    },
  },
  taxCode: {
    ...f.text({ label: "Tax" }),
    size: 72,
    field: {
      component: [Input],
      readPretty: () => true,
    },
  },
  batchNo: {
    ...f.text({ label: "Batch" }),
    size: 96,
    field: { component: [Input] },
  },
  neededBy: {
    ...f.date({ label: "Needed by" }),
    size: 130,
    field: true,
  },
  requestedBy: {
    ...f.text({ label: "Requester" }),
    size: 120,
    field: {
      component: [
        ComboBox,
        { options: REQUESTER_OPTIONS, placeholder: "Who…", creatable: true },
      ],
      required: true,
    },
  },
  shipVia: {
    ...f.text({ label: "Ship via" }),
    size: 100,
    field: {
      component: [Select, { options: SHIP_VIA_OPTIONS, placeholder: "Via" }],
    },
  },
  costCenter: {
    ...f.text({ label: "Cost ctr" }),
    size: 96,
    field: {
      component: [ComboBox, { options: COST_CENTER_OPTIONS, placeholder: "CC…" }],
    },
  },
  notes: {
    ...f.text({ label: "Notes" }),
    size: 160,
    field: { component: [Input] },
  },
});

const CATALOG: Omit<ProcurementLine, "id" | "lineTotal">[] = [
  {
    sku: "MC-4100",
    item: "Motor controller rev C",
    qty: 3,
    unitPrice: 249.5,
    discountPct: 0,
    status: "review",
    uom: "EA",
    warehouse: "SEA-1",
    taxCode: "TX-01",
    batchNo: "B-8821",
    neededBy: new Date(2026, 6, 12),
    requestedBy: "Ivy Chen",
    shipVia: "Ground",
    costCenter: "OPS-240",
    notes: "Confirm firmware rev before ship",
  },
  {
    sku: "HK-220",
    item: "Harness kit 24-pin",
    qty: 8,
    unitPrice: 74,
    discountPct: 5,
    status: "draft",
    uom: "KIT",
    warehouse: "SFO-2",
    taxCode: "TX-01",
    batchNo: "",
    neededBy: new Date(2026, 6, 19),
    requestedBy: "Jae Park",
    shipVia: "Air",
    costCenter: "ENG-110",
    notes: "",
  },
  {
    sku: "BR-90",
    item: "Bearing race 90mm",
    qty: 20,
    unitPrice: 12.4,
    discountPct: 0,
    status: "approved",
    uom: "EA",
    warehouse: "SEA-1",
    taxCode: "TX-02",
    batchNo: "B-9010",
    neededBy: new Date(2026, 7, 1),
    requestedBy: "Sam Ortiz",
    shipVia: "Ground",
    costCenter: "OPS-240",
    notes: "Pair with seal kit SK-12",
  },
  {
    sku: "SK-12",
    item: "Seal kit (matched)",
    qty: 20,
    unitPrice: 6.8,
    discountPct: 0,
    status: "approved",
    uom: "KIT",
    warehouse: "SEA-1",
    taxCode: "TX-02",
    batchNo: "B-9011",
    neededBy: new Date(2026, 7, 1),
    requestedBy: "Sam Ortiz",
    shipVia: "Ground",
    costCenter: "OPS-240",
    notes: "",
  },
  {
    sku: "PSU-48",
    item: "48V PSU industrial",
    qty: 2,
    unitPrice: 410,
    discountPct: 10,
    status: "review",
    uom: "EA",
    warehouse: "DAL-3",
    taxCode: "TX-01",
    batchNo: "B-7702",
    neededBy: new Date(2026, 6, 28),
    requestedBy: "Mina Cho",
    shipVia: "Air",
    costCenter: "ENG-110",
    notes: "Needs UL sticker on carton",
  },
  {
    sku: "CAB-5M",
    item: "Shielded cable 5m",
    qty: 15,
    unitPrice: 18.25,
    discountPct: 0,
    status: "draft",
    uom: "EA",
    warehouse: "SFO-2",
    taxCode: "TX-03",
    batchNo: "",
    neededBy: new Date(2026, 7, 8),
    requestedBy: "Ivy Chen",
    shipVia: "Ground",
    costCenter: "OPS-240",
    notes: "Cut to length at dock",
  },
  {
    sku: "FAN-120",
    item: "Cooling fan 120mm",
    qty: 6,
    unitPrice: 22,
    discountPct: 0,
    status: "draft",
    uom: "EA",
    warehouse: "DAL-3",
    taxCode: "TX-01",
    batchNo: "B-6610",
    neededBy: new Date(2026, 7, 15),
    requestedBy: "Jae Park",
    shipVia: "Ground",
    costCenter: "FAC-050",
    notes: "",
  },
  {
    sku: "LBL-500",
    item: "Thermal label roll",
    qty: 4,
    unitPrice: 31.5,
    discountPct: 0,
    status: "review",
    uom: "RL",
    warehouse: "SEA-1",
    taxCode: "TX-03",
    batchNo: "",
    neededBy: new Date(2026, 6, 30),
    requestedBy: "Mina Cho",
    shipVia: "Ground",
    costCenter: "OPS-240",
    notes: "Compatible with Zebra ZT410",
  },
];

const SEED: ProcurementLine[] = CATALOG.map((row, i) => ({
  ...row,
  id: `pl-${i + 1}`,
  lineTotal: lineNet(row),
}));

function emptyLine(): ProcurementLine {
  return {
    id: crypto.randomUUID(),
    sku: "",
    item: "",
    qty: 1,
    unitPrice: 0,
    discountPct: 0,
    lineTotal: 0,
    status: "draft",
    uom: "EA",
    warehouse: "SEA-1",
    taxCode: "TX-01",
    batchNo: "",
    neededBy: null,
    requestedBy: "",
    shipVia: "Ground",
    costCenter: "",
    notes: "",
  };
}

export function EditableTableWideColumnsDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: ProcurementLine[] }>({
        initialValues: { lines: SEED },
        effects() {
          onFieldInputValueChange("lines.*.sku", (field) => {
            if (!isField(field)) return;
            const hit = SKU_MASTER.find((row) => row.sku === String(field.value ?? ""));
            if (!hit) return;
            const index = field.index as number;
            field.form.setValuesIn(`lines.${index}.item`, hit.item);
            field.form.setValuesIn(`lines.${index}.unitPrice`, hit.unitPrice);
            field.form.setValuesIn(`lines.${index}.uom`, hit.uom);
            field.form.setValuesIn(`lines.${index}.taxCode`, hit.taxCode);
          });
        },
      }),
    [],
  );

  return (
    <Form
      form={form}
      density="compact"
      onSubmit={async (values) => {
        toast.success(`Submitted ${values.lines.length} lines`);
      }}
    >
      <EditableTable<ProcurementLine>
        name="lines"
        variant="embedded"
        editMode="inline"
        tableCode="demo-wide-flat-matrix"
        columns={columns}
        getRowId={(row) => row.id}
        recordCreator={{ record: emptyLine }}
        features={{
          index: { mode: "view" },
          search: true,
          columns: { manager: true },
        }}
      />
      <FormActions>
        <Button type="submit">Submit</Button>
      </FormActions>
    </Form>
  );
}

Level 2 — Validation and Rules

Cell Validation Feedback

Problem: Field B must appear and become required when field A has a value on the same row.

This demo proves: field.reactions + validator on discountReason handle same-row rules; errors reveal on blur, not first keystroke.

Validation timing

Live cell errors follow punish late, reward early: tint on blur or submit, not the first keystroke. Focus a tinted cell to read the value-state message (Fiori-style) — not a hover tooltip.

Try it:

  1. Set Disc % to 10Disc reason appears.
  2. Tab away from empty reason — red tint on blur.
  3. Submit empty — rollup lists the row with a jump link.
Actions
"use client";

import { isField } from "@formily/core";
import { useMemo } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = {
  id: string;
  sku: string;
  discountPct: number;
  discountReason: string;
};

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 120,
    field: { component: [Input], readOnly: () => true },
  },
  discountPct: {
    ...f.number({
      label: "Disc %",
    }),
    size: 90,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0, max: 50 }],
      validator: (v) => {
        const n = Number(v);
        if (Number.isNaN(n) || n < 0 || n > 50) return "0–50%";
        return undefined;
      },
    },
  },
  discountReason: {
    ...f.text({
      label: "Disc reason",
    }),
    size: 180,
    field: {
      component: [Input, { placeholder: "Reason for discount" }],
      reactions: (field) => {
        const lines = field.form.values.lines as Line[] | undefined;
        const row = lines?.[field.index as number];
        const discounted = Number(row?.discountPct) > 0;
        field.display = discounted ? "visible" : "none";
        if (isField(field)) field.required = discounted;
      },
      validator: (value, row) => {
        const line = row as Line | undefined;
        if (Number(line?.discountPct) > 0 && !String(value ?? "").trim()) {
          return "Required when discount > 0";
        }
        return undefined;
      },
    },
  },
});

export function EditableTableCellValidationDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "l1", sku: "SKU-001", discountPct: 0, discountReason: "" },
            { id: "l2", sku: "SKU-002", discountPct: 0, discountReason: "" },
          ],
        },
      }),
    [],
  );

  return (
    <Form
      form={form}
      onSubmit={async () => {
        toast.success("Submitted");
      }}
    >
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        editMode="inline"
        features={{ footer: false, rowActions: false }}
      />
      <FormActions>
        <Button type="submit">
          Submit
        </Button>
      </FormActions>
    </Form>
  );
}

Identity-Keyed Host Validation

Problem: Host Validate / server / AI paints an error on a line, then the operator inserts or removes another line — chrome jumps to the wrong row because Formily paths use array indices.

This demo proves: applyEditableArrayIssues stores issues by rowId + colKey. After Insert row above, the kit rematerializes feedback onto the same row identity (SKU-B), not onto the new row that inherited the old index.

Anti-pattern: writing form.query("lines.1.qty").take().selfErrors = … or path-based applyFormIssues under the array once — that paint dies or sticks after structure change. Prefer the identity bridge (see Painting Validation on Rows).

Try it:

  1. Paint issue on SKU-B — Qty on SKU-B turns red (currently index 1).
  2. Insert row above — SKU-NEW lands at index 0; red tint must stay on SKU-B (now index 2).
  3. Status line under the toolbar reports Pass / Fail.
  4. Clear issues empties the identity bag for this table.

Paint an issue on SKU-B, then insert a row above — the red tint must stay on SKU-B.

Actions
"use client";

import { isField } from "@formily/core";
import { useMemo, useState } from "react";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import {
  applyEditableArrayIssues,
  clearEditableArrayIssues,
} from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = {
  id: string;
  sku: string;
  qty: number;
};

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 140,
    field: { component: [Input] },
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 100,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0 }],
    },
  },
});

/**
 * Host Validate paints by **row id**. After insert/reorder, chrome stays on
 * the same SKU — not on whatever row inherited the old array index.
 */
export function EditableTableArrayFeedbackIdentityDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "a", sku: "SKU-A", qty: 2 },
            { id: "b", sku: "SKU-B", qty: 99 },
            { id: "c", sku: "SKU-C", qty: 1 },
          ],
        },
      }),
    [],
  );

  const [status, setStatus] = useState(
    "Paint an issue on SKU-B, then insert a row above — the red tint must stay on SKU-B.",
  );

  const paintOnSkuB = () => {
    applyEditableArrayIssues(form, {
      arrayPath: "lines",
      issues: [
        {
          rowId: "b",
          colKey: "qty",
          message: "Qty exceeds available stock",
          source: "system",
        },
      ],
    });
    const field = form.query("lines.1.qty").take();
    const ok = isField(field) && (field.selfErrors?.[0] ?? "").includes("stock");
    setStatus(
      ok
        ? "Issue painted on SKU-B (index 1). Next: Insert row above."
        : "Paint failed — check that the table is mounted.",
    );
  };

  const insertAbove = () => {
    const lines = (form.values.lines ?? []) as Line[];
    form.setValues({
      ...form.values,
      lines: [
        { id: `z-${Date.now()}`, sku: "SKU-NEW", qty: 0 },
        ...lines,
      ],
    });
    // After structure change, SKU-B should be at index 2 with the same chrome.
    queueMicrotask(() => {
      const at0 = form.query("lines.0.qty").take();
      const at2 = form.query("lines.2.qty").take();
      const wrong =
        isField(at0) && (at0.selfErrors?.length ?? 0) > 0;
      const right =
        isField(at2) &&
        (at2.selfErrors?.[0] ?? "").includes("stock");
      if (right && !wrong) {
        setStatus(
          "Pass: chrome stayed on SKU-B (now index 2). Index 0 (SKU-NEW) is clean.",
        );
      } else if (wrong) {
        setStatus(
          "Fail: chrome stuck on index 0 after insert — that is the index-paint bug.",
        );
      } else {
        setStatus(
          "No chrome on SKU-B after insert — re-run Paint issue on SKU-B first.",
        );
      }
    });
  };

  const clearIssues = () => {
    clearEditableArrayIssues(form, { arrayPath: "lines" });
    setStatus("Cleared identity-keyed issues for this table.");
  };

  return (
    <Form form={form}>
      <p className="text-muted-foreground mb-3 text-sm" role="status">
        {status}
      </p>
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        editMode="inline"
        features={{ footer: false, rowActions: false }}
      />
      <FormActions>
        <Button type="button" onClick={paintOnSkuB}>
          Paint issue on SKU-B
        </Button>
        <Button type="button" variant="outline" onClick={insertAbove}>
          Insert row above
        </Button>
        <Button type="button" variant="ghost" onClick={clearIssues}>
          Clear issues
        </Button>
      </FormActions>
    </Form>
  );
}

Cross-Row Header Effects

Problem: A header field must react when any table row matches a rule.

This demo proves: Parent form effects and onFieldValueChange("lines.*.qty", …) drive header fields — not cell reactions.

Try it:

  1. Manager approval starts hidden.
  2. Set any Qty above 25 — the field appears and becomes required.
  3. Drop all qty to 25 or below — field hides and clears.
Actions
"use client";

import { onFieldValueChange } from "@formily/core";
import { useMemo } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = { id: string; item: string; qty: number };

const BULK_QTY = 25;

const columns = defineEditableColumns<Line>({
  item: {
    ...f.text({ label: "Item" }),
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({
      label: "Qty",
    }),
    size: 100,
    field: {
      component: [NumberInput, { surface: "tableCell" }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
    },
  },
});

export function EditableTableCrossRowDemo() {
  const form = useMemo(
    () =>
      createForm<{ managerApproval: string; lines: Line[] }>({
        initialValues: {
          managerApproval: "",
          lines: [
            { id: "l1", item: "Steel bracket", qty: 10 },
            { id: "l2", item: "Rubber gasket", qty: 4 },
          ],
        },
        effects(form) {
          const syncApprovalGate = () => {
            const lines = (form.values?.lines ?? []) as Line[];
            const needsApproval = lines.some(
              (row) => Number(row.qty) > BULK_QTY,
            );
            form.setFieldState("managerApproval", (state) => {
              state.display = needsApproval ? "visible" : "none";
              state.required = needsApproval;
              if (!needsApproval) state.value = "";
            });
          };
          onFieldValueChange("lines.*.qty", syncApprovalGate);
          onFieldValueChange("lines", syncApprovalGate);
          syncApprovalGate();
        },
      }),
    [],
  );

  return (
    <Form
      form={form}
      onSubmit={async () => {
        toast.success("Submitted");
      }}
    >
      <FormField
        name="managerApproval"
        label="Manager approval"
        description={`Required when any line qty > ${BULK_QTY}`}
        component={[Input, { placeholder: "MGMT-OK" }]}
      />
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        editMode="inline"
        recordCreator={{
          record: () => ({ id: crypto.randomUUID(), item: "", qty: 1 }),
        }}
      />
      <FormActions>
        <Button type="submit">
          Submit
        </Button>
      </FormActions>
    </Form>
  );
}

Submit Validation

Problem: Invalid rows hidden by search or paging must still block submit.

This demo proves: Submit scans the full form.values array; rollup jump links find hidden rows.

Error surfaces (kit-enforced):

SurfaceRole
Cell value stateLocal chrome + message
EditableErrorRollupUnder-table list + jump — same Banner card as Form Error Summary, cell paths only
FormErrorSummary (scope="all")Root + header + array-root (lineItems); kit drops owned cell descendants (lineItems.*)

Editable Table registers its ArrayField as the cell-error owner (descendants name.*). Parent FormErrorSummary scope="all" drops those cell issues (dev console.warn when strips happen) and keeps header / array-root paths. Keep FormPage’s default summary when the page also has header or array-root rules. Pure cell errors only: errorSummary={false} (rollup owns aggregate) or scope="form" for system/root (path == null) only — scope="form" does not list header or array-root issues. See Object Messaging And Table Chrome.

Try it:

  1. Validate & submit — row 2 fails (empty task, zero hours).
  2. Search wire — row 2 hides from the view but still fails validation.
  3. Click a rollup jump link — search clears and the table scrolls to the row.
Actions
Wireframe4
0
Review2
"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type Task = { id: string; title: string; hours: number };

const columns = defineEditableColumns<Task>({
  title: {
    ...f.text({ label: "Task" }),
    field: { component: [Input], required: true },
  },
  hours: {
    ...f.number({
      label: "Hours",
    }),
    size: 100,
    field: {
      component: [NumberInput, { surface: "tableCell" }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "must be > 0"),
    },
  },
});

export function EditableTableValidationDemo() {
  const form = useMemo(
    () =>
      createForm<{ tasks: Task[] }>({
        initialValues: {
          tasks: [
            { id: "t1", title: "Wireframe", hours: 4 },
            { id: "t2", title: "", hours: 0 },
            { id: "t3", title: "Review", hours: 2 },
          ],
        },
      }),
    [],
  );

  return (
    <Form
      form={form}
      onSubmit={async () => {
        toast.success("Submitted successfully");
      }}
    >
      <EditableTable<Task>
        name="tasks"
        columns={columns}
        getRowId={(r) => r.id}
        recordCreator={{
          record: () => ({ id: crypto.randomUUID(), hours: 1 }),
        }}
        features={{ search: true, sorting: true }}
      />
      <FormActions>
        <Button type="submit">
          Validate & submit
        </Button>
      </FormActions>
    </Form>
  );
}

Level 3 — Real Forms

Complete Order Entry

Problem: You need header cards, line items, cross-row totals, and per-row rules in one form.

This demo proves: Capstone — effects, reactions, and validators together. Patterns from Cell Validation Feedback and Cross-Row Header Effects appear here.

Business rules

AreaRuleMechanism
Line subtotalQty × price net of discountfield.reactions on the subtotal column (editor form — live while editing)
Order totalNet of line discounts + freightParent effects on lineItems array change (post-commit)
Freight waiverFreight becomes $0 and read-only when line subtotal ≥ $1,000effects
PO numberRequired when order total ≥ $500reactions
Manager approvalShown and required when any line qty > 25 or order total ≥ $1,000reactions
Ship dateRequired when status is Confirmed or Shipped; cannot be before order datesetFieldState + validator
Disc reasonColumn appears and is required when that row's Disc % > 0reactions + row validator
Shipped lockHeader fields and every line cell become read-onlyeffects
ConfirmedCannot remove the last linerowActionsPolicy

Try it:

  1. Disc reason — Set Disc % to 10, blur empty reason, then submit.
  2. Manager approval — Set Qty to 30 on a line.
  3. Freight waiver — Raise a line subtotal past $1,000.
  4. PO number — With order total ≥ $500, PO number becomes required.
  5. Ship date — Set Status to Confirmed with Ship date before Order date.
  6. Status → Shipped — entire form locks read-only.
Order
Header fields, approvals, and derived total

Shipped locks the order for editing

6172026
mmddyyyy

Required when confirmed; must be on or after order date

Optional below $500

USD 338.75

Net of line discounts plus freight

Customer
Bill-to contact
Line items
Set Disc % on a row to reveal Disc reason; try qty > 25 to surface manager approval
#
SKU
Name
Qty
Price
Disc %
Disc reason
Subtotal
Actions
1
39.80
2
89.00
3
50.00
4
26.25
5
37.20
6
28.00
7
1.50
8
42.00
Shipping
Ship-to address and freight
Notes
Internal memo on the order
"use client";

import {
  isArrayField,
  isField,
  onFieldValueChange,
  type Field,
} from "@formily/core";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";

import { formatMoneyForDisplay } from "@/components/f-ui/currency-format/currency-format";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { CurrencyInput } from "@/components/f-ui/formily/connects/currency-input";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

import { CATALOG_LINES, ORDER_SEED } from "./editable-table-demo-shared";
import { f } from "@/components/f-ui/field-types/catalog";

const ORDER_STATUS = [
  { label: "Draft", value: "draft" },
  { label: "Confirmed", value: "confirmed" },
  { label: "Shipped", value: "shipped" },
];

const PO_REQUIRED_THRESHOLD = 500;
const FREIGHT_WAIVER_SUBTOTAL = 1_000;
const BULK_QTY_THRESHOLD = 25;
const MANAGER_APPROVAL_TOTAL = 1_000;

const INITIAL_LINES = ORDER_SEED.map((line) => ({
  ...line,
  discountPct: 0,
  discountReason: "",
}));

const INITIAL_FREIGHT = 25;

type OrderLineItem = {
  id: string;
  sku: string;
  name: string;
  qty: number;
  price: number;
  discountPct: number;
  discountReason: string;
};

type SalesOrderValues = {
  orderNo: string;
  status: "draft" | "confirmed" | "shipped";
  orderDate: Date | null;
  shipDate: Date | null;
  poNumber: string;
  managerApproval: string;
  customerName: string;
  customerEmail: string;
  customerPhone: string;
  shipTo: string;
  freight: number;
  notes: string;
  orderTotal: number;
  lineItems: OrderLineItem[];
};

type OrderForm = ReturnType<typeof createForm<SalesOrderValues>>;

function startOfDay(date: Date) {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}

function lineGross(row: OrderLineItem) {
  return Number(row.qty) * Number(row.price);
}

function lineNet(row: OrderLineItem) {
  const gross = lineGross(row);
  const pct = Math.min(50, Math.max(0, Number(row.discountPct) || 0));
  return gross * (1 - pct / 100);
}

function lineSubtotal(lines: OrderLineItem[]) {
  return lines.reduce((sum, row) => sum + lineNet(row), 0);
}

function hasBulkLine(lines: OrderLineItem[]) {
  return lines.some((row) => Number(row.qty) > BULK_QTY_THRESHOLD);
}

function recalcOrderTotal(form: OrderForm) {
  const values = form.values;
  if (!values) return;
  const subtotal = lineSubtotal(values.lineItems ?? []);
  const freight = Number(values.freight) || 0;
  form.setValuesIn("orderTotal", subtotal + freight);
}

function syncFreightPolicy(form: OrderForm) {
  const values = form.values;
  if (!values) return;
  const waived =
    lineSubtotal(values.lineItems ?? []) >= FREIGHT_WAIVER_SUBTOTAL;
  const locked = waived || values.status === "shipped";

  form.setFieldState("freight", (state) => {
    state.readOnly = locked;
    state.description = waived
      ? "Waived — line subtotal is $1,000+"
      : values.status === "shipped"
        ? "Locked after shipment"
        : "";
  });

  if (waived && Number(values.freight) !== 0) {
    form.setValuesIn("freight", 0);
  }
}

function syncHeaderReadOnly(form: OrderForm, shipped: boolean) {
  const lockablePaths = [
    "orderNo",
    "orderDate",
    "shipDate",
    "poNumber",
    "managerApproval",
    "customerName",
    "customerEmail",
    "customerPhone",
    "shipTo",
    "notes",
  ] as const;

  for (const path of lockablePaths) {
    form.setFieldState(path, (state) => {
      state.readOnly = shipped;
    });
  }

  form.setFieldState("freight", (state) => {
    if (shipped) {
      state.readOnly = true;
      state.description = "Locked after shipment";
    }
  });

  if (!shipped) syncFreightPolicy(form);
}

function validateShipDate(
  value: Date | null | undefined,
  _rule: unknown,
  ctx: { field?: Field },
) {
  if (!value || !ctx.field) return "";
  const orderDate = ctx.field.query("orderDate").value() as Date | null;
  if (!orderDate) return "";
  if (startOfDay(value) < startOfDay(orderDate)) {
    return "Ship date cannot be before order date";
  }
  return "";
}

const orderEntryColumns = defineEditableColumns<OrderLineItem>({
  sku: {
    ...f.text({ label: "SKU", sortKey: "sku" }),
    size: 120,
    field: { component: [Input], required: true },
  },
  name: {
    ...f.text({ label: "Name", sortKey: "name" }),
    field: { component: [Input] },
  },
  qty: {
    ...f.number({
      label: "Qty",
      sortKey: "qty",
    }),
    size: 90,
    field: {
      component: [NumberInput, { surface: "tableCell" }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "needs > 0"),
    },
  },
  price: {
    ...f.currency({ label: "Price", currency: "USD", sortKey: "price", measure: "none" }),
    size: 110,
    field: {
      component: [CurrencyInput, { currency: "USD", surface: "tableCell", measure: "none" }],
    },
  },
  discountPct: {
    ...f.number({
      label: "Disc %",
      sortKey: "discountPct",
    }),
    size: 90,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0, max: 50 }],
      validator: (v) => {
        const n = Number(v);
        if (Number.isNaN(n) || n < 0 || n > 50) return "0–50%";
        return undefined;
      },
    },
  },
  discountReason: {
    ...f.text({
      label: "Disc reason",
    }),
    size: 140,
    field: {
      // Placeholder is a neutral hint — deliberately worded differently from the
      // validator message so an empty cell's gray placeholder is never mistaken
      // for an (un-flagged) validation error. The red cell tint + "Required when
      // discount > 0" message appear once the empty required field is blurred or
      // the form is submitted.
      component: [Input, { placeholder: "Reason for discount" }],
      reactions: (field) => {
        const lines = field.form.values.lineItems as
          | OrderLineItem[]
          | undefined;
        const row = lines?.[field.index as number];
        const discounted = Number(row?.discountPct) > 0;
        field.display = discounted ? "visible" : "none";
        if (isField(field)) {
          field.required = discounted;
        }
      },
      validator: (value, row) => {
        const line = row as OrderLineItem | undefined;
        if (Number(line?.discountPct) > 0 && !String(value ?? "").trim()) {
          return "Required when discount > 0";
        }
        return undefined;
      },
    },
  },
  subtotal: {
    ...f.currency({
      label: "Subtotal",
      currency: "USD",
      measure: "none",
      accessor: (row) => lineNet(row),
    }),
    size: 120,
    field: {
      component: [Input],
      readPretty: () => true,
      reactions: (field) => {
        const qty = Number(field.query(".qty").value()) || 0;
        const price = Number(field.query(".price").value()) || 0;
        const discountPct = Number(field.query(".discountPct").value()) || 0;
        if (isField(field)) {
          field.value = lineNet({ qty, price, discountPct } as OrderLineItem);
        }
      },
    },
    footer: (rows) =>
      formatMoneyForDisplay(
        rows.reduce((sum, row) => sum + lineNet(row as OrderLineItem), 0),
        "USD",
        undefined,
        { measure: "none" },
      ),
  },
});

export function EditableTableOrderEntryDemo() {
  const [isShipped, setIsShipped] = useState(false);

  const orderForm = useMemo(
    () =>
      createForm<SalesOrderValues>({
        initialValues: {
          orderNo: "SO-2026-0042",
          status: "draft",
          orderDate: new Date(2026, 5, 17),
          shipDate: null,
          poNumber: "",
          managerApproval: "",
          customerName: "Northwind Traders",
          customerEmail: "orders@northwind.example",
          customerPhone: "+1 555-0100",
          shipTo: "123 Harbor Way\nSeattle, WA 98101",
          freight: INITIAL_FREIGHT,
          notes: "",
          orderTotal: lineSubtotal(INITIAL_LINES) + INITIAL_FREIGHT,
          lineItems: INITIAL_LINES,
        },
        effects(form) {
          form.setFieldState("orderTotal", (state) => {
            state.pattern = "readPretty";
          });

          const syncShipDateRequired = (status: SalesOrderValues["status"]) => {
            form.setFieldState("shipDate", (state) => {
              state.required = status === "confirmed" || status === "shipped";
            });
          };

          const syncStatusRules = (status: SalesOrderValues["status"]) => {
            syncShipDateRequired(status);
            const shipped = status === "shipped";
            setIsShipped(shipped);
            syncHeaderReadOnly(form, shipped);
          };

          syncStatusRules(form.values?.status ?? "draft");

          onFieldValueChange("status", (field) => {
            syncStatusRules(field.value as SalesOrderValues["status"]);
          });

          const onLinesOrPricingChange = () => {
            syncFreightPolicy(form);
            recalcOrderTotal(form);
          };

          recalcOrderTotal(form);
          syncFreightPolicy(form);

          onFieldValueChange("freight", () => recalcOrderTotal(form));
          // Inline cells write directly to form.values — totals and freight update live.
          onFieldValueChange("lineItems.*.qty", onLinesOrPricingChange);
          onFieldValueChange("lineItems.*.price", onLinesOrPricingChange);
          onFieldValueChange("lineItems.*.discountPct", onLinesOrPricingChange);

          onFieldValueChange("orderDate", () => {
            form.validate("shipDate").catch(() => undefined);
          });
        },
      }),
    [],
  );

  const [savedOrder, setSavedOrder] = useState<SalesOrderValues | null>(null);

  const addFromCatalog = useCallback(() => {
    const field = orderForm.query("lineItems").take();
    if (!field || !isArrayField(field)) return;
    for (const line of CATALOG_LINES) {
      field.push({
        id: crypto.randomUUID(),
        ...line,
        discountPct: 0,
        discountReason: "",
      });
    }
  }, [orderForm]);

  const rowActionsPolicy = useCallback(
    (
      _row: OrderLineItem,
      _meta: { editable: boolean; hasErrors: boolean; isNew: boolean },
    ) => {
      const count = orderForm.values?.lineItems?.length ?? 0;
      const confirmed = orderForm.values?.status === "confirmed";
      return {
        remove:
          confirmed && count <= 1
            ? { disabled: "Confirmed orders must keep at least one line" }
            : undefined,
      };
    },
    [orderForm],
  );

  return (
    <Form
      form={orderForm}
      density="compact"
      onSubmit={async (v) => {
        setSavedOrder(v);
        toast.success("Submitted successfully");
      }}
    >
      <div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] xl:grid-cols-2">
        <Card className="h-fit">
          <CardHeader>
            <CardTitle>Order</CardTitle>
            <CardDescription>
              Header fields, approvals, and derived total
            </CardDescription>
          </CardHeader>
          <CardContent>
            <div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)] md:grid-cols-2">
              <FormField
                name="orderNo"
                label="Order #"
                required
                kind="text"
                componentProps={{ placeholder: "SO-0000" }}
              />
              <FormField
                name="status"
                label="Status"
                required
                kind="select"
                componentProps={{ options: ORDER_STATUS }}
                description="Shipped locks the order for editing"
              />
              <FormField
                name="orderDate"
                label="Order date"
                required
                kind="date"
              />
              <FormField
                name="shipDate"
                label="Ship date"
                kind="date"
                description="Required when confirmed; must be on or after order date"
                validator={{
                  triggerType: "onInput",
                  validator: validateShipDate,
                }}
              />
              <FormField
                name="poNumber"
                label="PO number"
                kind="text"
                componentProps={{ placeholder: "Customer PO" }}
                reactions={(field) => {
                  const total = Number(field.query("orderTotal").value()) || 0;
                  const required = total >= PO_REQUIRED_THRESHOLD;
                  field.required = required;
                  field.description = required
                    ? `Required for orders $${PO_REQUIRED_THRESHOLD}+`
                    : "Optional below $500";
                }}
              />
              <FormField
                name="managerApproval"
                label="Manager approval"
                kind="text"
                componentProps={{ placeholder: "MGMT-OK" }}
                reactions={(field) => {
                  field.query("lineItems.*.qty").value();
                  field.query("lineItems.*.price").value();
                  field.query("lineItems.*.discountPct").value();
                  field.query("orderTotal").value();
                  const lines = (field.query("lineItems").value() ??
                    []) as OrderLineItem[];
                  const total = Number(field.query("orderTotal").value()) || 0;
                  const needsApproval =
                    hasBulkLine(lines) || total >= MANAGER_APPROVAL_TOTAL;
                  field.visible = needsApproval;
                  field.required = needsApproval;
                  field.description = hasBulkLine(lines)
                    ? `Required when any line qty > ${BULK_QTY_THRESHOLD}`
                    : needsApproval
                      ? `Required when order total ≥ $${MANAGER_APPROVAL_TOTAL}`
                      : "";
                }}
              />
              <div className="md:col-span-2">
                <FormField
                  name="orderTotal"
                  label="Order total"
                  kind="currency"
                  componentProps={{ currency: "USD" }}
                  description="Net of line discounts plus freight"
                />
              </div>
            </div>
          </CardContent>
        </Card>

        <Card className="h-fit">
          <CardHeader>
            <CardTitle>Customer</CardTitle>
            <CardDescription>Bill-to contact</CardDescription>
          </CardHeader>
          <CardContent>
            <div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)]">
              <FormField
                name="customerName"
                label="Company"
                required
                kind="text"
                componentProps={{ placeholder: "Acme Corp" }}
              />
              <FormField
                name="customerEmail"
                label="Email"
                required
                kind="email"
                componentProps={{ placeholder: "billing@acme.example" }}
              />
              <FormField
                name="customerPhone"
                label="Phone"
                kind="phone"
                componentProps={{ placeholder: "+1 555-0100" }}
              />
            </div>
          </CardContent>
        </Card>

        <Card className="xl:col-span-2">
          <CardHeader>
            <CardTitle>Line items</CardTitle>
            <CardDescription>
              Set Disc % on a row to reveal Disc reason; try qty &gt; 25 to
              surface manager approval
            </CardDescription>
          </CardHeader>
          <CardContent className="space-y-0">
            <EditableTable<OrderLineItem>
              name="lineItems"
              variant="embedded"
              editMode="inline"
              tableCode="demo-order-entry-lines"
              columns={orderEntryColumns}
              getRowId={(r) => r.id}
              readOnly={isShipped}
              rowActionsPolicy={rowActionsPolicy}
              recordCreator={{
                record: () => ({
                  id: crypto.randomUUID(),
                  qty: 1,
                  price: 0,
                  discountPct: 0,
                  discountReason: "",
                }),
              }}
              toolbar={{
                trailing: (
                  <Button
                    type="button"
                    variant="outline"
                    onClick={addFromCatalog}
                    disabled={isShipped}
                  >
                    Add from catalog
                  </Button>
                ),
              }}
              features={{
                index: { mode: "view" },
              }}
            />
          </CardContent>
        </Card>

        <Card className="h-fit">
          <CardHeader>
            <CardTitle>Shipping</CardTitle>
            <CardDescription>Ship-to address and freight</CardDescription>
          </CardHeader>
          <CardContent>
            <div className="grid grid-cols-1 gap-[var(--fui-form-field-gap)]">
              <FormField
                name="shipTo"
                label="Ship to"
                required
                kind="textarea"
                componentProps={{
                  rows: 3,
                  placeholder: "Street, city, postal code",
                }}
              />
              <FormField
                name="freight"
                label="Freight"
                kind="currency"
                componentProps={{ currency: "USD" }}
              />
            </div>
          </CardContent>
        </Card>

        <Card className="h-fit">
          <CardHeader>
            <CardTitle>Notes</CardTitle>
            <CardDescription>Internal memo on the order</CardDescription>
          </CardHeader>
          <CardContent>
            <FormField
              name="notes"
              label="Notes"
              kind="textarea"
              componentProps={{
                rows: 4,
                placeholder: "Optional instructions for fulfillment",
              }}
            />
          </CardContent>
        </Card>
      </div>

      <FormActions>
        <Button type="submit">
          Save order
        </Button>
      </FormActions>

      {savedOrder ? (
        <pre className="bg-muted mt-3 overflow-auto rounded-md p-3 text-xs">
          <code>{JSON.stringify(savedOrder, null, 2)}</code>
        </pre>
      ) : null}
    </Form>
  );
}

Specialized — Pick When You Need It

Remote Persistence

Problem: Each row must hit the server before it commits or leaves the array.

This demo proves: editable.onSave / onDelete. Return a row to swap the client id. Reject to keep saveError. Duplicate is CREATE (meta.isNew).

Try it:

  1. EditSave (~800ms) → row commits.
  2. Duplicate a row — clone sits under the source. SaveId becomes srv-….
  3. Arm next Save failure, then Save — row stays editing until retry succeeds.

Rows persist on Save / Delete. Duplicate, then Save — the Id column shows the server id. Parent submit is not the persist path.

Actions
r1SKU-1002
r2SKU-2005
"use client";

import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { f } from "@/components/f-ui/field-types/catalog";

type RemoteLine = { id: string; sku: string; qty: number };

const columns = defineEditableColumns<RemoteLine>({
  id: {
    ...f.text({ label: "Id" }),
    size: 140,
  },
  sku: {
    ...f.text({ label: "SKU" }),
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({
      label: "Qty",
    }),
    size: 90,
    field: {
      component: [NumberInput, { surface: "tableCell" }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "needs > 0"),
    },
  },
});

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

export function EditableTableRemotePersistenceDemo() {
  const [simulateFailure, setSimulateFailure] = useState(false);
  const failNextSave = useRef(false);
  const failNextDelete = useRef(false);

  const form = useMemo(
    () =>
      createForm<{ lines: RemoteLine[] }>({
        initialValues: {
          lines: [
            { id: "r1", sku: "SKU-100", qty: 2 },
            { id: "r2", sku: "SKU-200", qty: 5 },
          ],
        },
      }),
    [],
  );

  const onSave = useCallback(
    async (
      _rowId: string,
      row: RemoteLine,
      _origin: RemoteLine | undefined,
      meta: { isNew: boolean },
    ) => {
      await delay(800);
      if (failNextSave.current) {
        failNextSave.current = false;
        throw new Error(
          meta.isNew ? "Server rejected new line" : "Save failed — retry",
        );
      }
      if (meta.isNew) {
        const saved = { ...row, id: `srv-${crypto.randomUUID().slice(0, 8)}` };
        toast.success(`Line created (${saved.id})`);
        return saved;
      }
      toast.success("Line saved on server");
    },
    [],
  );

  const onDelete = useCallback(async () => {
    await delay(600);
    if (failNextDelete.current) {
      failNextDelete.current = false;
      throw new Error("Delete failed — retry");
    }
    toast.success("Line deleted on server");
  }, []);

  const armSaveFailure = useCallback(() => {
    failNextSave.current = true;
    toast.message("Next Save will fail");
  }, []);

  const armDeleteFailure = useCallback(() => {
    failNextDelete.current = true;
    toast.message("Next Delete will fail");
  }, []);

  return (
    <>
      <div className="mb-3 flex flex-wrap items-center gap-4">
        <div className="flex items-center gap-2">
          <Switch
            id="simulate-failure"
            checked={simulateFailure}
            onCheckedChange={setSimulateFailure}
          />
          <Label htmlFor="simulate-failure">Simulate server errors</Label>
        </div>
        {simulateFailure ? (
          <>
            <Button
              type="button"
              variant="outline"
              onClick={armSaveFailure}
            >
              Arm next Save failure
            </Button>
            <Button
              type="button"
              variant="outline"
              onClick={armDeleteFailure}
            >
              Arm next Delete failure
            </Button>
          </>
        ) : null}
      </div>
      <Form form={form}>
        <p className="text-muted-foreground mb-3 text-xs leading-relaxed">
          Rows persist on Save / Delete. Duplicate, then Save — the Id column shows the
          server id. Parent submit is not the persist path.
        </p>
        <EditableTable<RemoteLine>
          name="lines"
          tableCode="demo-remote-lines"
          columns={columns}
          getRowId={(r) => r.id}
          recordCreator={{
            record: () => ({ id: crypto.randomUUID(), sku: "", qty: 1 }),
          }}
          editable={{ onSave, onDelete }}
          features={{ sorting: true }}
        />
      </Form>
    </>
  );
}

Row Edit Gates

Problem: Approved rows must not be editable; pending rows still can be.

This demo proves: rowEditable locks whole rows — no Edit, muted read cells.

Try it:

  1. Approved row — no Edit button (whole row locked via rowEditable).
  2. Pending row — click Edit.
  3. Rejected row — Remove is hidden (never-enable; do not gray it).
AP-2024-17
Actions
Steel bracket420.00ApprovedPassed QA
Rubber gasket85.00Pending
40.00RejectedMissing spec sheet
Copper bushing120.00PendingAwaiting quote
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

import { approvalColumns, type ApprovalLine } from "./editable-table-demo-shared";

type ApprovalValues = { batchId: string; lines: ApprovalLine[] };

export function EditableTableApprovalDemo() {
  const approvalForm = useMemo(
    () =>
      createForm<ApprovalValues>({
        initialValues: {
          batchId: "AP-2024-17",
          lines: [
            { id: "ok", item: "Steel bracket", amount: 420, status: "approved", note: "Passed QA" },
            { id: "wait", item: "Rubber gasket", amount: 85, status: "pending", note: "" },
            { id: "bad", item: "", amount: 40, status: "rejected", note: "Missing spec sheet" },
            { id: "hold", item: "Copper bushing", amount: 120, status: "pending", note: "Awaiting quote" },
          ],
        },
      }),
    [],
  );
  const [savedApproval, setSavedApproval] = useState<ApprovalValues | null>(null);

  return (
    <Form
      form={approvalForm}
      onSubmit={async (v) => {
        setSavedApproval(v);
        toast.success("Submitted successfully");
      }}
    >
      <FormField name="batchId" label="Batch #" component={[Input]} readPretty />
      <EditableTable<ApprovalLine>
        name="lines"
        tableCode="demo-approval-lines"
        columns={approvalColumns}
        getRowId={(r) => r.id}
        rowEditable={(r) => r.status !== "approved"}
        // Approved rows hide Remove via `rowEditable` (no actions on locked rows).
        // Rejected lines cannot be removed — hide, do not gray (never-enable).
        rowActionsPolicy={(row) =>
          row.status === "rejected" ? { remove: { hidden: true } } : undefined
        }
        recordCreator={{
          record: () => ({
            id: crypto.randomUUID(),
            status: "pending",
            amount: 0,
            note: "",
          }),
        }}
        features={{ sorting: true }}
      />
      <FormActions>
        <Button type="submit">
          Submit batch
        </Button>
      </FormActions>
      {savedApproval ? (
        <pre className="bg-muted mt-3 rounded-md p-3 text-xs">
          <code>{JSON.stringify(savedApproval, null, 2)}</code>
        </pre>
      ) : null}
    </Form>
  );
}

Mixed Column Gates

Problem: One column locks on an approved row while siblings stay editable.

This demo proves: field.readOnly per column; f.enum({ render: "status" }) without field stays display-only.

Try it:

  1. Approved row — Amount locked, Note editable.
  2. Pending row — edit any cell.
  3. Submit with a rejected row and empty Note — the rollup owns the required message; Edit that row to see the value-state ring. Do not fake this with cellClassName.
Actions
Steel bracket420.00ApprovedPassed QA
Rubber gasket85.00Pending
Copper bushing120.00Rejected
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

import { mixedGateColumns, type ApprovalLine } from "./editable-table-demo-shared";

type Values = { lines: ApprovalLine[] };

export function EditableTableMixedGatesDemo() {
  const form = useMemo(
    () =>
      createForm<Values>({
        initialValues: {
          lines: [
            { id: "a", item: "Steel bracket", amount: 420, status: "approved", note: "Passed QA" },
            { id: "b", item: "Rubber gasket", amount: 85, status: "pending", note: "" },
            { id: "c", item: "Copper bushing", amount: 120, status: "rejected", note: "" },
          ],
        },
      }),
    [],
  );
  const [saved, setSaved] = useState<Values | null>(null);

  return (
    <Form
      form={form}
      onSubmit={async (v) => {
        setSaved(v);
        toast.success("Submitted successfully");
      }}
    >
      <EditableTable<ApprovalLine>
        name="lines"
        columns={mixedGateColumns}
        getRowId={(r) => r.id}
        features={{ sorting: true }}
      />
      <FormActions>
        <Button type="submit">
          Submit
        </Button>
      </FormActions>
      {saved ? (
        <pre className="bg-muted mt-3 rounded-md p-3 text-xs">
          <code>{JSON.stringify(saved, null, 2)}</code>
        </pre>
      ) : null}
    </Form>
  );
}

Mixed Pattern

Problem: Qty is fillable but locked this session; Subtotal is computed knowledge. Those must not look the same.

This demo proves: field.readOnly keeps a Qty control (blank when empty); field.readPretty keeps Subtotal as display (em dash when empty). SKU and Price stay editable. Same pair as Form (Formily) — Mixed Pattern.

Try it:

  1. Line 1 — Qty is a readonly number control; Subtotal is formatted text.
  2. Line 2 — empty Qty stays blank; empty Subtotal is .
  3. Change Price on either row — Subtotal updates; Qty still cannot be typed.
Actions
39.80
Sum39.80
"use client";

import { isField } from "@formily/core";
import { useMemo, useState } from "react";
import { toast } from "sonner";

import { formatMoneyForDisplay } from "@/components/f-ui/currency-format/currency-format";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { CurrencyInput } from "@/components/f-ui/formily/connects/currency-input";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
import { Button } from "@/components/ui/button";

type Line = {
  id: string;
  sku: string;
  qty: number | null;
  price: number;
  subtotal: number | null;
};

type Values = { lines: Line[] };

function lineNet(qty: number | null, price: number) {
  if (qty == null) return null;
  return qty * price;
}

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 120,
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 96,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0, step: 1 }],
      readOnly: () => true,
    },
  },
  price: {
    ...f.currency({ label: "Price", currency: "USD", measure: "none" }),
    size: 110,
    field: {
      component: [CurrencyInput, { currency: "USD", surface: "tableCell", measure: "none" }],
    },
  },
  subtotal: {
    ...f.currency({
      label: "Subtotal",
      currency: "USD",
      measure: "none",
      accessor: (row) => lineNet(row.qty, row.price),
    }),
    size: 120,
    field: {
      component: [
        CurrencyInput,
        { currency: "USD", surface: "tableCell", measure: "none" },
      ],
      readPretty: () => true,
      reactions: (field) => {
        if (!isField(field)) return;
        const qty = field.query(".qty").value() as number | null;
        const price = Number(field.query(".price").value()) || 0;
        field.value = lineNet(qty, price);
      },
    },
    footer: (rows) =>
      formatMoneyForDisplay(
        rows.reduce((sum, row) => sum + (lineNet(row.qty, row.price) ?? 0), 0),
        "USD",
        undefined,
        { measure: "none" },
      ),
  },
});

export function EditableTableMixedPatternDemo() {
  const form = useMemo(
    () =>
      createForm<Values>({
        initialValues: {
          lines: [
            {
              id: "l1",
              sku: "SKU-104",
              qty: 2,
              price: 19.9,
              subtotal: 39.8,
            },
            {
              id: "l2",
              sku: "SKU-200",
              qty: null,
              price: 8.5,
              subtotal: null,
            },
          ],
        },
      }),
    [],
  );
  const [saved, setSaved] = useState<Values | null>(null);

  return (
    <Form
      form={form}
      onSubmit={async (values) => {
        setSaved(values);
        toast.success("Submitted successfully");
      }}
    >
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(row) => row.id}
        editMode="inline"
        features={{ sorting: true, footer: true }}
      />
      <FormActions>
        <Button type="submit">
          Submit
        </Button>
      </FormActions>
      {saved ? (
        <pre className="bg-muted mt-3 rounded-md p-3 text-xs">
          <code>{JSON.stringify(saved, null, 2)}</code>
        </pre>
      ) : null}
    </Form>
  );
}

Virtual Scroll

Problem: 50+ rows must scroll smoothly without mounting every <tr>.

This demo proves: features.view.virtual window the DOM; footer and search still see the full filtered set.

Try it:

  1. Scroll — only visible rows mount.
  2. Search or sort — footer still sums all matching rows.
  3. Use offset paging instead when users expect page numbers.

All 55 rows

#
Actions
"use client";

import { useMemo, useState } from "react";
import { toast } from "sonner";

import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { FormField } from "@/components/f-ui/formily/form-field";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";

import { lineColumns, ORDER_SEED, type LineItem } from "./editable-table-demo-shared";

const VIRTUAL_SCROLL_SEED: LineItem[] = Array.from({ length: 55 }, (_, i) => {
  const base = ORDER_SEED[i % ORDER_SEED.length]!;
  return {
    id: `vs-${i + 1}`,
    sku: `SKU-${String(i + 1).padStart(3, "0")}`,
    name: base.name,
    qty: base.qty + (i % 5),
    price: base.price,
  };
});

type OrderValues = { orderNo: string; lineItems: LineItem[] };

export function EditableTableVirtualScrollDemo() {
  const orderForm = useMemo(
    () =>
      createForm<OrderValues>({
        initialValues: { orderNo: "SO-9001", lineItems: VIRTUAL_SCROLL_SEED },
      }),
    [],
  );
  const [savedOrder, setSavedOrder] = useState<OrderValues | null>(null);

  return (
    <Form
      form={orderForm}
      onSubmit={async (v) => {
        setSavedOrder(v);
        toast.success("Submitted successfully");
      }}
    >
      <FormField name="orderNo" label="Order #" component={[Input]} />
      <EditableTable<LineItem>
        name="lineItems"
        tableCode="demo-order-lines-virtual"
        columns={lineColumns}
        getRowId={(r) => r.id}
        recordCreator={{
          record: () => ({ id: crypto.randomUUID(), qty: 1, price: 0 }),
        }}
        features={{
          index: { mode: "view" },
          view: { mode: "virtual", maxHeight: "min(360px, 50vh)" },
          sorting: true,
          search: true,
          footer: true,
        }}
      />
      <FormActions>
        <Button type="submit">
          Save order
        </Button>
      </FormActions>
      {savedOrder ? (
        <pre className="bg-muted mt-3 rounded-md p-3 text-xs">
          <code>{JSON.stringify(savedOrder, null, 2)}</code>
        </pre>
      ) : null}
    </Form>
  );
}

Large Dataset (1k–2k Rows)

Problem: ~500–2000 already-persisted rows must stay interactive; occasional edit and Duplicate then Save.

This demo proves: editMode="row" + onSave / onDelete + features.view.virtual (or offset). Persist unit is one row. Virtualization is DOM-only. Duplicate default "after".

For app-viewport mount / wide-matrix QA (not this persist recipe), use the showcase: Large Lines (Submit pick list is a Formily stress harness).

Try it:

  1. Leave 2000 + virtual — scroll, search a SKU, sort Qty.
  2. Edit a row → Save — toast for that row, not the whole array.
  3. Duplicate — clone lands under the source; Save — new srv-* id stays in that slot.
  4. Compare offset pages (20) vs virtual scroll.
  5. Select rows — footer batch bar Delete selected.

Seeded 2,000 rows in 120 ms · virtual

Persist unit is one row (Save / Delete / Duplicate then Save). Virtualization only cuts DOM — it does not submit the array. Duplicate inserts under the source row.

All 2000 rows

#
Actions
"use client";

import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";

import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { lineColumns, ORDER_SEED, type LineItem } from "./editable-table-demo-shared";

type RowCount = 500 | 1000 | 2000;
type ViewMode = "virtual" | "offset";

type OrderValues = { lineItems: LineItem[] };

const ROW_COUNTS: RowCount[] = [500, 1000, 2000];

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

let serverSeq = 0;

function buildLines(count: number): LineItem[] {
  return Array.from({ length: count }, (_, i) => {
    const base = ORDER_SEED[i % ORDER_SEED.length]!;
    return {
      id: `lg-${i + 1}`,
      sku: `SKU-${String(i + 1).padStart(4, "0")}`,
      name: `${base.name} #${i + 1}`,
      qty: (i % 20) + 1,
      price: base.price,
    };
  });
}

function createOrderForm(count: RowCount) {
  const started = performance.now();
  const form = createForm<OrderValues>({
    initialValues: { lineItems: buildLines(count) },
  });
  return { form, seedMs: Math.round(performance.now() - started) };
}

/**
 * Persisted-grid recipe at 500–2000 rows — virtual (or offset) view + row
 * Save / Delete. Remounts the form when size or view changes so timings stay
 * comparable.
 */
export function EditableTableLargeDatasetDemo() {
  const [rowCount, setRowCount] = useState<RowCount>(2000);
  const [viewMode, setViewMode] = useState<ViewMode>("virtual");
  const [session, setSession] = useState(0);

  const { form, seedMs } = useMemo(() => createOrderForm(rowCount), [rowCount, session]);

  const remount = (patch?: {
    rowCount?: RowCount;
    viewMode?: ViewMode;
  }) => {
    if (patch?.rowCount != null) setRowCount(patch.rowCount);
    if (patch?.viewMode != null) setViewMode(patch.viewMode);
    setSession((n) => n + 1);
  };

  const onSave = useCallback(
    async (_rowId: string, row: LineItem, _origin: LineItem | undefined, meta: { isNew: boolean }) => {
      await delay(200);
      if (meta.isNew) {
        const saved = { ...row, id: `srv-${++serverSeq}` };
        toast.success(`Created ${saved.id}`);
        return saved;
      }
      toast.success(`Saved ${row.id}`);
    },
    [],
  );

  const onDelete = useCallback(async (_rowId: string, row: LineItem) => {
    await delay(150);
    toast.success(`Deleted ${row.id}`);
  }, []);

  const viewFeature =
    viewMode === "virtual"
      ? ({ mode: "virtual", maxHeight: "min(560px, 70vh)" } as const)
      : ({ mode: "offset", pageSize: 20 } as const);

  return (
    <div className="flex flex-col gap-3">
      <div className="bg-muted/40 flex flex-wrap items-end gap-3 rounded-lg border p-3">
        <div className="flex min-w-36 flex-col gap-1.5">
          <Label htmlFor="fat-large-rows">Rows</Label>
          <Select
            value={String(rowCount)}
            onValueChange={(v) => remount({ rowCount: Number(v) as RowCount })}
          >
            <SelectTrigger id="fat-large-rows" size="sm" className="w-full">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {ROW_COUNTS.map((n) => (
                <SelectItem key={n} value={String(n)}>
                  {n.toLocaleString()}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>

        <div className="flex min-w-36 flex-col gap-1.5">
          <Label htmlFor="fat-large-view">View</Label>
          <Select
            value={viewMode}
            onValueChange={(v) => remount({ viewMode: v as ViewMode })}
          >
            <SelectTrigger id="fat-large-view" size="sm" className="w-full">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="virtual">Virtual scroll</SelectItem>
              <SelectItem value="offset">Offset pages (20)</SelectItem>
            </SelectContent>
          </Select>
        </div>

        <Button
          type="button"
          variant="outline"
          onClick={() => remount()}
        >
          Remount
        </Button>

        <p className="text-muted-foreground w-full text-xs tabular-nums sm:ml-auto sm:w-auto">
          Seeded {rowCount.toLocaleString()} rows in {seedMs} ms · {viewMode}
        </p>
      </div>

      <p className="text-muted-foreground text-xs leading-relaxed">
        Persist unit is one row (Save / Delete / Duplicate then Save). Virtualization
        only cuts DOM — it does not submit the array. Duplicate inserts under the
        source row.
      </p>

      <Form
        key={`${session}-${rowCount}-${viewMode}`}
        form={form}
      >
        <EditableTable<LineItem>
          name="lineItems"
          tableCode={`demo-large-lines-${viewMode}`}
          columns={lineColumns}
          editMode="row"
          getRowId={(r) => r.id}
          recordCreator={{
            record: () => ({
              id: crypto.randomUUID(),
              sku: "",
              name: "",
              qty: 1,
              price: 0,
            }),
          }}
          editable={{ onSave, onDelete }}
          features={{
            index: { mode: "view" },
            sorting: true,
            search: true,
            footer: true,
            selection: { batchDelete: true },
            view: viewFeature,
          }}
        />
      </Form>
    </div>
  );
}

Read-Only Review

Problem: Users may browse and sort but must not edit.

This demo proves: readOnly — sort and footer work; no add or row actions.

Try it:

  1. Confirm no Edit or Add controls.
  2. Click a column header — sort works.
  3. Check footer subtotal.
SKU-001Wrench219.9039.80
SKU-010Socket set189.0089.00
SKU-020Drill bit412.5050.00
SKU-030Tape measure38.7526.25
Sum205.05
"use client";

import { useMemo } from "react";

import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";

import { ORDER_SEED, lineColumns, type LineItem } from "./editable-table-demo-shared";

export function EditableTableReadonlyDemo() {
  const form = useMemo(
    () =>
      createForm<{ lineItems: LineItem[] }>({
        initialValues: { lineItems: ORDER_SEED.slice(0, 4) },
      }),
    [],
  );

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <EditableTable<LineItem>
        name="lineItems"
        columns={lineColumns}
        getRowId={(r) => r.id}
        readOnly
        features={{ sorting: true, footer: true }}
      />
    </Form>
  );
}

Pattern Boundary

Problem: Operators mix fillable, locked, computed, and display columns in one grid.

This demo proves: the decision test — Qty is editable, Price is readOnly until a customer is chosen, Subtotal is readPretty, SKU has no field. Pretty empty is —, locked Price stays blank.

Decision test: can this role change it now → editable. Locked this session → readOnly. Never a control here → readPretty or omit field. Empty pretty cells show —, locked controls stay blank.

SKU
Qty
Price
Subtotal
Actions
SKU-1
Choose a customer before editing price
Choose a customer before editing price
"use client";

import { isField } from "@formily/core";
import { useMemo } from "react";

import { formatMoneyForDisplay } from "@/components/f-ui/currency-format/currency-format";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { CurrencyInput } from "@/components/f-ui/formily/connects/currency-input";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { FormField } from "@/components/f-ui/formily/form-field";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = {
  id: string;
  sku: string;
  qty: number | null;
  price: number | null;
  subtotal: number | null;
};

function net(qty: number | null, price: number | null) {
  if (qty == null || price == null) return null;
  return qty * price;
}

export function EditableTablePatternBoundaryDemo() {
  const form = useMemo(
    () =>
      createForm<{ customer: string; lines: Line[] }>({
        initialValues: {
          customer: "",
          lines: [
            { id: "a", sku: "SKU-1", qty: 2, price: null, subtotal: null },
            { id: "b", sku: "", qty: null, price: 12, subtotal: null },
          ],
        },
      }),
    [],
  );

  const columns = useMemo(
    () =>
      defineEditableColumns<Line>({
        sku: {
          ...f.text({ label: "SKU" }),
          size: 110,
        },
        qty: {
          ...f.number({ label: "Qty" }),
          size: 88,
          field: {
            component: [NumberInput, { surface: "tableCell", min: 0, step: 1 }],
            required: true,
          },
        },
        price: {
          ...f.currency({ label: "Price", currency: "USD", measure: "none" }),
          size: 110,
          field: {
            component: [CurrencyInput, { currency: "USD", surface: "tableCell", measure: "none" }],
            readOnly: () => !String(form.values.customer ?? ""),
            lockReason: () =>
              String(form.values.customer ?? "")
                ? undefined
                : "Choose a customer before editing price",
          },
        },
        subtotal: {
          ...f.currency({
            label: "Subtotal",
            currency: "USD",
            measure: "none",
            accessor: (row) => net(row.qty, row.price),
          }),
          size: 120,
          field: {
            component: [
              CurrencyInput,
              { currency: "USD", surface: "tableCell", measure: "none" },
            ],
            readPretty: () => true,
            reactions: (field) => {
              if (!isField(field)) return;
              const qty = field.query(".qty").value() as number | null;
              const price = field.query(".price").value() as number | null;
              field.value = net(qty, price);
            },
          },
          footer: (rows) =>
            formatMoneyForDisplay(
              rows.reduce((sum, row) => sum + (net(row.qty, row.price) ?? 0), 0),
              "USD",
              undefined,
              { measure: "none" },
            ),
        },
      }),
    [form],
  );

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <p className="text-muted-foreground mb-3 text-sm">
        Decision test: can this role change it now → <strong>editable</strong>.
        Locked this session → <strong>readOnly</strong>. Never a control here →{" "}
        <strong>readPretty</strong> or omit <code>field</code>. Empty pretty
        cells show —, locked controls stay blank.
      </p>
      <FormField
        name="customer"
        kind="text"
        label="Customer"
        componentProps={{ placeholder: "Type a name to unlock Price" }}
      />
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        editMode="inline"
        variant="embedded"
      />
    </Form>
  );
}

Display Vs Edit

Problem: Display mode must not grow input-shaped error rings, and must not paint a wall of ghost Number Inputs.

This demo proves: Display is pretty text (field.readPretty); table readOnly only hides add/actions. Unlock remounts controls inside one field box. Qty does not grow or show steppers.

SKU
Qty
SKU-1
2
0
"use client";

import { useMemo, useState } from "react";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { applyEditableArrayIssues } from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
import { Button } from "@/components/ui/button";

type Line = { id: string; sku: string; qty: number };

export function EditableTableDisplayVsEditDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "ok", sku: "SKU-1", qty: 2 },
            { id: "bad", sku: "", qty: 0 },
          ],
        },
      }),
    [],
  );
  const [mode, setMode] = useState<"display" | "edit">("display");
  const columns = useMemo(
    () =>
      defineEditableColumns<Line>({
        sku: {
          ...f.text({ label: "SKU" }),
          size: 140,
          grow: true,
          field: {
            component: [Input],
            required: true,
            readPretty: () => mode === "display",
          },
        },
        qty: {
          ...f.number({ label: "Qty" }),
          size: 96,
          field: {
            component: [NumberInput, { surface: "tableCell", min: 0 }],
            required: true,
            readPretty: () => mode === "display",
          },
        },
      }),
    [mode],
  );

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <div className="mb-3 flex items-center gap-2">
        <Button
          type="button"
          variant={mode === "display" ? "default" : "outline"}
          onClick={() => {
            setMode("display");
            form.setPattern("readPretty");
          }}
        >
          Display
        </Button>
        <Button
          type="button"
          variant={mode === "edit" ? "default" : "outline"}
          onClick={() => {
            setMode("edit");
            form.setPattern("editable");
            applyEditableArrayIssues(form, {
              arrayPath: "lines",
              issues: [
                { rowId: "bad", colKey: "sku", message: "Required", source: "server" },
              ],
            });
          }}
        >
          Unlock
        </Button>
      </div>
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        editMode="inline"
        variant="embedded"
        readOnly={mode === "display"}
      />
    </Form>
  );
}

Severity Routing

Problem: Host issues on locked cells used to live only on the cell.

This demo proves: an editable cell paints + focus message; a locked cell is counted in the rollup only.

Same payload, two destinations: the editable SKU paints a value-state ring (message on focus). The locked Qty is counted in the rollup only — no ring.

SKU
Qty
Actions
Pending warehouse count
"use client";

import { useEffect, useMemo } from "react";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { applyEditableArrayIssues } from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = { id: string; sku: string; qty: number };

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 140,
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 96,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0 }],
      readOnly: (row) => (row as Line).id === "locked",
      lockReason: (row) =>
        (row as Line).id === "locked" ? "Pending warehouse count" : undefined,
    },
  },
});

export function EditableTableSeverityRoutingDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "edit", sku: "SKU-1", qty: 2 },
            { id: "locked", sku: "SKU-2", qty: 40 },
          ],
        },
      }),
    [],
  );

  useEffect(() => {
    applyEditableArrayIssues(form, {
      arrayPath: "lines",
      issues: [
        {
          rowId: "edit",
          colKey: "sku",
          message: "Unknown SKU in catalog",
          source: "server",
          severity: "error",
        },
        {
          rowId: "locked",
          colKey: "qty",
          message: "Count exceeds remaining allocation",
          source: "server",
          severity: "warning",
        },
      ],
    });
  }, [form]);

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <p className="text-muted-foreground mb-3 text-sm">
        Same payload, two destinations: the editable SKU paints a value-state
        ring (message on focus). The locked Qty is counted in the rollup only —
        no ring.
      </p>
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        editMode="inline"
        variant="embedded"
      />
    </Form>
  );
}

Extra Copy On Display Cells

Problem: Browse cells need a host suffix (Bulk, SLA, source marker) without replacing kind, and without wrapping the NumberInput while the row is editing.

This demo proves: renderCell wraps formatted Qty on display. Edit that row — the control has no Bulk suffix. Save — the suffix returns. Edit stays field.component.

Try it:

  1. Row BRK-04 Qty shows 12 Bulk.
  2. Edit that row — type in Qty; there is no Bulk label on the input.
  3. Save — Bulk is back on the formatted number.
SKU
Qty
Actions
BRK-0412Bulk
GSK-114
BSH-021
"use client";

import { useMemo } from "react";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = { id: string; sku: string; qty: number };

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 140,
    grow: true,
    field: true,
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 140,
    field: true,
    renderCell: (formatted, row) =>
      row.qty >= 10 ? (
        <span className="inline-flex items-center gap-1">
          {formatted}
          <span className="text-muted-foreground text-xs">Bulk</span>
        </span>
      ) : (
        formatted
      ),
  },
});

export function EditableTableCellWrapDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "1", sku: "BRK-04", qty: 12 },
            { id: "2", sku: "GSK-11", qty: 4 },
            { id: "3", sku: "BSH-02", qty: 1 },
          ],
        },
      }),
    [],
  );

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        variant="embedded"
      />
    </Form>
  );
}

Cell Wash Without A Ring

Problem: A price over a host threshold should wash the cell, not grow a fake error ring.

This demo proves: cellClassName paints GSK-11 ($180). Rows under $100 return undefined and stay unpainted. Do not put kit ring classes on this seam.

Try it:

  1. GSK-11 Price has a warning wash; the other prices do not.
  2. Edit GSK-11 — the wash is cell paint, not a value-state ring. Focus does not open an error popover for the wash.
  3. Change Price below $100 and Save — wash clears.
SKU
Price
Actions
BRK-0442.00
GSK-11180.00
BSH-0218.00
"use client";

import { useMemo } from "react";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = { id: string; sku: string; price: number };

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 140,
    grow: true,
    field: true,
  },
  price: {
    ...f.currency({ label: "Price", currency: "USD", measure: "none" }),
    size: 140,
    field: true,
    cellClassName: (row) => (row.price > 100 ? "bg-warning/10" : undefined),
  },
});

export function EditableTableCellWashDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "1", sku: "BRK-04", price: 42 },
            { id: "2", sku: "GSK-11", price: 180 },
            { id: "3", sku: "BSH-02", price: 18 },
          ],
        },
      }),
    [],
  );

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        variant="embedded"
      />
    </Form>
  );
}

Change Review

Problem: An approver must see which cells changed, at a glance.

This demo proves: one composed job — not a separate API. kind formats Qty / Price, renderCell wraps with CellCriticalityValue (host-owned reason), getRowHighlight paints the row bar, the rollup lists jump targets. See the single-layer proofs above first.

Try it:

  1. Qty on Steel bracket is warning + 8 → 12 reason; Price on Gasket is error + threshold copy.
  2. Click a rollup item — jump to that cell.
  3. Compare with Extra Copy and Cell Wash — those are the same layers, one job each.

One composed example of cell customization: renderCell wraps formatted values, getRowHighlight paints the row, rollup jumps. Reason copy is host-owned.

Item
Qty
Price
Source
Steel bracketCritical12812Positive42.00Positivepo
GasketInformation4Negative180.00Over $100 thresholdInformationestimated
BushingInformation1Positive18.00Positivepo
"use client";

import { useEffect, useMemo } from "react";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { applyEditableArrayIssues } from "@/components/f-ui/editable-table/lib/editable-array-issues";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";
import { CellCriticalityValue } from "@/components/f-ui/table/lib/cell-criticality";
import type { RowHighlight } from "@/components/f-ui/table/lib/row-highlight";

type Line = {
  id: string;
  item: string;
  qty: number;
  priorQty: number;
  price: number;
  source: string;
};

const columns = defineEditableColumns<Line>({
  item: {
    ...f.text({ label: "Item" }),
    size: 140,
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 120,
    renderCell: (formatted, row) => {
      const changed = row.qty !== row.priorQty;
      return (
        <CellCriticalityValue
          criticality={changed ? "warning" : "info"}
          reason={
            changed ? (
              <span>
                {row.priorQty}
                {" → "}
                {row.qty}
              </span>
            ) : undefined
          }
        >
          {formatted}
        </CellCriticalityValue>
      );
    },
  },
  price: {
    ...f.currency({ label: "Price", currency: "USD", measure: "none" }),
    size: 140,
    renderCell: (formatted, row) => {
      const over = row.price > 100;
      return (
        <CellCriticalityValue
          criticality={over ? "error" : "success"}
          reason={over ? "Over $100 threshold" : undefined}
        >
          {formatted}
        </CellCriticalityValue>
      );
    },
  },
  source: {
    ...f.text({ label: "Source" }),
    size: 160,
    renderCell: (formatted, row) => (
      <CellCriticalityValue
        criticality={row.source === "estimated" ? "info" : "success"}
      >
        {formatted}
      </CellCriticalityValue>
    ),
  },
});

export function EditableTableChangeReviewDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            {
              id: "a",
              item: "Steel bracket",
              qty: 12,
              priorQty: 8,
              price: 42,
              source: "po",
            },
            {
              id: "b",
              item: "Gasket",
              qty: 4,
              priorQty: 4,
              price: 180,
              source: "estimated",
            },
            {
              id: "c",
              item: "Bushing",
              qty: 1,
              priorQty: 1,
              price: 18,
              source: "po",
            },
          ],
        },
      }),
    [],
  );

  useEffect(() => {
    applyEditableArrayIssues(form, {
      arrayPath: "lines",
      issues: [
        {
          rowId: "a",
          colKey: "qty",
          message: "Applicant changed quantity",
          source: "system",
          severity: "warning",
        },
        {
          rowId: "b",
          colKey: "price",
          message: "Over $100 threshold",
          source: "system",
          severity: "error",
        },
      ],
    });
  }, [form]);

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <p className="text-muted-foreground mb-3 text-sm">
        One composed example of cell customization: <code>renderCell</code> wraps
        formatted values, <code>getRowHighlight</code> paints the row, rollup jumps.
        Reason copy is host-owned.
      </p>
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        readOnly
        variant="embedded"
        getRowHighlight={(row): RowHighlight => {
          if (row.price > 100) return "error";
          if (row.qty !== row.priorQty) return "warning";
          return "none";
        }}
      />
    </Form>
  );
}

Lock Reason

Problem: A locked cell must explain why, without looking invalid.

This demo proves: muted lock mark + reason on focus — never destructive tokens. Placement, field vs surface scope, and hard bans: Field Lock Affordance.

Locked cells stay muted, never red, and Qty stays writable so the difference is scannable in one pass. Focus a locked control to read why. Ship via is a locked composite: the lock takes the chevron’s place instead of crowding beside it.

SKU
Ship via
Qty
Actions
You do not have permission to edit this line
Read-only
Carrier is set by the shipping plan
Locked once the line is posted to the ledger
Read-only
Carrier is set by the shipping plan
Unlocks after the customer is confirmed
Read-only
Carrier is set by the shipping plan
"use client";

import { useMemo } from "react";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Select } from "@/components/f-ui/formily/connects/select";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";

type Line = {
  id: string;
  sku: string;
  shipVia: string;
  qty: number;
  lock: "permission" | "posted" | "dependency";
};

// Field-scoped reasons only. "Another user is editing this row" is a row lock
// (spec L14) and belongs to row chrome, not to a field mark.
const REASON: Record<Line["lock"], string> = {
  permission: "You do not have permission to edit this line",
  posted: "Locked once the line is posted to the ledger",
  dependency: "Unlocks after the customer is confirmed",
};

const SHIP_VIA = [
  { label: "Air", value: "air" },
  { label: "Ground", value: "ground" },
];

const columns = defineEditableColumns<Line>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 140,
    field: {
      component: [Input],
      readOnly: () => true,
      lockReason: (row) => REASON[(row as Line).lock],
    },
  },
  // Composite archetype: the trailing lock replaces the chevron rather than
  // sitting beside it (spec L19).
  shipVia: {
    ...f.text({ label: "Ship via" }),
    size: 110,
    field: {
      component: [Select, { options: SHIP_VIA, surface: "tableCell" }],
      readOnly: () => true,
      lockReason: () => "Carrier is set by the shipping plan",
    },
  },
  qty: {
    ...f.number({ label: "Qty" }),
    size: 96,
    field: {
      component: [NumberInput, { surface: "tableCell", min: 0 }],
    },
  },
});

export function EditableTableLockReasonDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: Line[] }>({
        initialValues: {
          lines: [
            { id: "a", sku: "SKU-1", shipVia: "air", qty: 2, lock: "permission" },
            { id: "b", sku: "SKU-2", shipVia: "ground", qty: 4, lock: "posted" },
            {
              id: "c",
              sku: "SKU-3",
              shipVia: "air",
              qty: 1,
              lock: "dependency",
            },
          ],
        },
      }),
    [],
  );

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <p className="text-muted-foreground mb-3 text-sm">
        Locked cells stay muted, never red, and Qty stays writable so the
        difference is scannable in one pass. Focus a locked control to read why.
        Ship via is a locked composite: the lock takes the chevron&rsquo;s place
        instead of crowding beside it.
      </p>
      <EditableTable<Line>
        name="lines"
        columns={columns}
        getRowId={(r) => r.id}
        editMode="inline"
        variant="embedded"
      />
    </Form>
  );
}

Headless Usage (Specialized)

Problem: You need a custom shell (drawer, split pane) without bundled chrome.

This demo proves: useEditableTable inside ArrayField + EditableTableContext — same state machine as the bundled table.

Try it:

  1. Open the Code tab — HeadlessTable wires useEditableTable, TableView, and EditableErrorRollup.
  2. Scroll the table and paginate.
  3. Click Add from catalog — calls fat.addRows.

→ Full hook API: Headless Usage below.

Define Your Columns

defineEditableColumns<RowType>({ … }) is the single source of truth for headers, read formatting, edit wiring, and footer math.

PropertyRequired?What it does
kindYesRead/display format — text, number, currency, date, status, boolean, etc. Same renderer in browse and edit.
labelYesColumn header text.
fieldNoWhen present, mounts a Field (editable, readOnly, or readPretty). Omit for display-only columns (status tags, computed accessors with no reactions).
field.componentNoFormily connect tuple, e.g. [Input] or [NumberInput, { step: 0.5 }]. Omit with field: true to infer from kind (number → NumberInput, currency → CurrencyInput).
field.validatorNoPer-cell rules. Receives (value, row) for cross-field checks inside a row.
field.reactionsNoFormily reactions — display, required, derived value while the field is mounted.
field.readOnlyNo(row) => boolean — session-lock this control on an otherwise editable row (pattern="readOnly").
field.readPrettyNo(row) => boolean — display / computed while still mounting a Field for reactions (pattern="readPretty").
accessorNo(row) => value when the displayed value is not a plain property (computed subtotal, joined label).
renderCellNo(formatted, row) => ReactNode — wrap the already-formatted display node. Does not replace kind. Display only; skipped when masked / pending / mask.
cellClassNameNostring or (row, meta) => string | undefined — paint the cell. Never rebuild a value-state ring with it.
footerNo(rows) => ReactNode | number — summary cell when features.footer is on.
tierNo"primary" (default) — matrix column. "secondary" — renders in the expandable row-detail panel when expand is enabled.
sizeNoDefault column width hint for resize.
growNoAbsorb leftover width. Implicit default skips number / currency (identity / notes grow instead). Explicit grow: true still wins.

Three column shapes you'll use daily:

// 1. Editable scalar — kind formats read text; field: true (or { required }) infers the control
sku: { kind: "text", label: "SKU", field: { required: true } },
qty: { kind: "number", label: "Qty", decimals: 2, field: true }, // → NumberInput step 0.01

// 2. Display-only — no field; always read text (status tag, rollup)
status: f.enum({ render: "status", label: "Status", variants: PLAN_VARIANTS, accessor: (r) => r.plan }),

// 3. Computed read + optional edit on siblings
subtotal: {
  kind: "currency",
  label: "Subtotal",
  accessor: (row) => row.qty * row.price,
  field: { readPretty: () => true, reactions: (f) => { /* live while editing */ } },
},

Where to put cross-row logic

Inside one row (show field B when column A has a value): field.reactions + validator on column B — see Cell Validation Feedback.

Across rows or into header fields (order total, manager approval): parent form effects on the committed array — see Cross-Row Header Effects. In row mode, header totals update after each Save, not on every keystroke in the draft editor.

Cell Customization

Four layers, same as Table. Do not invent a fifth render that replaces kind (enum already owns render: "status" | "label").

LayerAPIJob
1 GetaccessorWhich value
2 FormatkindCurrency, date, status tag, …
3 ContentrenderCell(formatted, row)Wrap the formatted display node
4 PaintcellClassNameClass on the cell. Function may receive meta (editable / locked / hasCellError)
Host wantsUse
Extra words / badge on browserenderCellExtra Copy On Display Cells
Semantic colour + iconrenderCell + CellCriticalityValue
Cell wash, not a ringcellClassNameCell Wash Without A Ring
Approver scanCompose the layers + getRowHighlight + rollup — Change Review
Fix this value nowEditable value-state ring (not cellClassName)
Why this control is locked (the lock itself is automatic)field.lockReason

Do

  • Keep field.component as the edit control. renderCell never wraps it.
  • Return undefined from cellClassName when the row should not paint.
  • Skip wrapping when the cell is masked / pending / mask.

Don't

  • Put editableTableCellWarningClass (or border-destructive) on cellClassName.
  • Expect Bulk / criticality chrome on the NumberInput while the row is editing.
  • Treat Change Review as a kit prop — it is one composition.

Browse-only Table scenarios (status + host badge, combined scan): Table — Cell Customization.

Nested Sub-Rows (Master–Detail)

Use nested when each parent row owns a different child entity (claims, split quantities, sub-lines) with its own column schema. The chevron opens an inset child Editable Table — not a progressive-disclosure panel of leftover parent fields.

NeedPattern
Extra fields of the same entitytier: "secondary" + row-detail expand — see Wide Rows: Column Tiers & Row Detail
Child entities that belong to / sum into the parentnested on Editable Table (this section)

One Chevron Owner

nested and tier: "secondary" are mutually exclusive on the same table instance. Passing both throws in development — only one pattern may own the expand chevron. Promote scan fields to primary, or open identity in a drawer, instead of stacking both expand modes.

Model A (always-array): keep children on parent.claims[] (or similar) at all times. Unsplit = [] or one child; split = two or more. Edit claim fields on children only. Parent columns may show derived remaining / Σ via accessor / footer — the kit does not ship a mutating rollup that writes back into parent fields.

Try it:

  1. Expand a parent — populated lines show the child claim grid; the empty line shows Empty + Add sub-row.
  2. Add or edit allocation qty — parent Remaining updates from capacity − Σ allocations.qty (display only).
  3. Note the nested region is an inset under the row — no Card chrome.
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";

<EditableTable
  name="lines"
  columns={parentColumns}
  getRowId={(row) => row.id}
  nested={{
    path: "claims",
    columns: claimColumns,
    recordCreator: {
      record: () => ({ id: crypto.randomUUID(), qty: 1, note: "" }),
    },
  }}
/>
#
SKU
Item
Capacity
Remaining
Actions
1
4
Claim qty
Note
Actions
2
8
Claim qty
Note
Actions

No sub-rows

Add a sub-row to split this line.

Sum12
"use client";

import { useMemo } from "react";
import { toast } from "sonner";

import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import { EditableTable } from "@/components/f-ui/editable-table/editable-table";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { Input } from "@/components/f-ui/formily/connects/input";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { Button } from "@/components/ui/button";
import { f } from "@/components/f-ui/field-types/catalog";

type Claim = {
  id: string;
  qty: number;
  note: string;
};

type CatalogLine = {
  id: string;
  sku: string;
  item: string;
  /** Static capacity — host owns this; kit does not mutate it. */
  capacity: number;
  claims: Claim[];
};

function claimedQty(row: CatalogLine): number {
  return (row.claims ?? []).reduce((sum, c) => sum + (Number(c.qty) || 0), 0);
}

function remainingQty(row: CatalogLine): number {
  return Number(row.capacity) - claimedQty(row);
}

const parentColumns = defineEditableColumns<CatalogLine>({
  sku: {
    ...f.text({ label: "SKU" }),
    size: 120,
    field: { component: [Input], required: true },
  },
  item: {
    ...f.text({ label: "Item" }),
    size: 180,
    field: { component: [Input], required: true },
  },
  capacity: {
    ...f.number({ label: "Capacity" }),
    size: 100,
    field: true,
  },
  remaining: {
    ...f.number({
      label: "Remaining",
      accessor: (row) => remainingQty(row),
    }),
    size: 110,
    footer: (rows) =>
      rows.reduce((sum, row) => sum + remainingQty(row as CatalogLine), 0),
  },
});

const claimColumns = defineEditableColumns<Claim>({
  qty: {
    ...f.number({ label: "Claim qty" }),
    size: 110,
    field: { required: true },
  },
  note: {
    ...f.text({ label: "Note" }),
    field: { component: [Input] },
  },
});

const SEED: CatalogLine[] = [
  {
    id: "l1",
    sku: "A-100",
    item: "Motor controller",
    capacity: 10,
    claims: [
      { id: "c1", qty: 4, note: "Batch A" },
      { id: "c2", qty: 2, note: "Batch B" },
    ],
  },
  {
    id: "l2",
    sku: "B-220",
    item: "Harness kit",
    capacity: 8,
    claims: [],
  },
];

export function EditableTableNestedDemo() {
  const form = useMemo(
    () =>
      createForm<{ lines: CatalogLine[] }>({
        initialValues: { lines: SEED },
      }),
    [],
  );

  return (
    <Form
      form={form}
      density="compact"
      onSubmit={async (values) => {
        toast.success(`Submitted ${values.lines.length} lines`);
      }}
    >
      <EditableTable<CatalogLine>
        name="lines"
        variant="embedded"
        editMode="inline"
        tableCode="demo-nested-sub-rows"
        columns={parentColumns}
        getRowId={(row) => row.id}
        nested={{
          path: "claims",
          columns: claimColumns,
          getChildRowId: (c) => c.id,
          defaultExpanded: true,
          recordCreator: {
            record: () => ({
              id: crypto.randomUUID(),
              qty: 1,
              note: "",
            }),
          },
        }}
        recordCreator={{
          record: () => ({
            id: crypto.randomUUID(),
            sku: "",
            item: "",
            capacity: 1,
            claims: [],
          }),
        }}
        features={{ index: { mode: "view" }, footer: true }}
      />
      <FormActions>
        <Button type="submit">Submit</Button>
      </FormActions>
    </Form>
  );
}

Core Concepts

Shared View Tier

Editable Table keeps Formily ArrayField as the data and edit engine, but renders through the same data-source-agnostic view primitives as Table (src/components/f-ui/table/):

PrimitiveRole in Editable Table
TableViewMatrix body — sort headers, selection, optional virtual scrollport
buildExpandColumnChevron column + expand state — shared with Table expandable
build-row-actions-columnIdle-row Edit / Duplicate / Remove + overflow menu
use-column-managerHide / reorder / pin when features.columns.manager is on
renderCellByKindRead-pretty cells (EditableFieldCell delegates here)
data-list-table i18n bundleShared view chrome — actions.*, columns.*, density.*, …

The Plus registry item declares a dependency on table. Editable cells stay package-owned; only browse chrome and layout are shared.

System column IDs (for column manager + tableCode persistence):

IDColumn
__expandExpand chevron when secondary columns or expand.enabled (order: after selection, before index/data)
__indexOptional ordinal column (features.index)
__actionsTrailing row-actions column (Edit / Duplicate / Remove)

Row actions layout matches the data-list table: Edit and Duplicate stay inline; Remove is placement: "trailing" with variant: "destructive" (right-aligned action cell). Custom rowActions items and overflow extras collapse into a MoreVertical menu. Shared confirm copy (actions.confirmTitle, actions.confirm, actions.cancel) comes from the data-list-table bundle; form-specific remove strings (row.confirmTitle, …) override when you pass removeRowConfirm.

Internationalization

All keys use dotted paths. Two bundles cooperate:

BundleHost idExamples
data-list-table (shared view)"data-list-table"actions.more, columns.trigger, selection.selectRow
editable-table (form domain)"editable-table"row.edit, validation.pendingEditsBlock, batchDelete.trigger, footer.totalAllRows

Built-in strings resolve through useEditableTableI18n() (form keys) and useTableI18n() (view keys). Override either via I18nProvider — pass the matching component name to your host t.

Two Data Planes

Row mode separates what is saved from what is being typed:

flowchart LR
  subgraph committed [Committed plane]
    A["form.values.lineItems"]
  end
  subgraph draft [Draft plane - row mode only]
    B["editorForm per row"]
  end
  Browse["Browse cells"] --> A
  Edit["Click Edit"] --> B
  B -->|Save| A
  B -->|Cancel| X["Discarded"]
  Inline["Inline mode"] --> A
PlaneWhat it isWhen it updates
CommittedThe Formily ArrayField at name — saved rows onlyOn Save, Delete, or direct array mutations (addRows, bulk catalog push)
DraftOne transient createForm() per editing row (flat ${colKey} paths)While a row is in edit mode; disposed on Save/Cancel

Read cells mount no inputs. Editing cells mount their row's editor form via a nested FormProvider. Within-row derived values (e.g. line subtotal) can use field.reactions in the editor form; cross-row totals belong in parent-form effects keyed on the committed array (onFieldValueChange("lineItems", …)), not per-field keystroke listeners.

Inline mode (editMode="inline") has no draft plane — every editable cell writes directly to form.values.

Choosing an Edit Mode

Row (default)Inline
Mental modelOperational grid — commit one row at a timeRepeating field blocks inside a big form
CellsRead text at rest; click Edit (or a cell) to open a row editorAlways-live inputs bound to form.values
SubmitBlocked while any row is mid-editNo pending-edit gate
Remote saveeditable.onSave / onDelete per row. onSave may return the saved row (server id / etag).Use parent-form submit — row hooks are ignored
Best forAlready-persisted catalog, Duplicate then SaveDocument line items (order create, rebate Apply)

What features Actually Does

FlagBehavior
viewRow viewport strategy. Default { mode: "all" } (every row mounts). offset — client-side page slices over the filtered/sorted view; renders EditablePagination. virtual — scrollport with TanStack Virtual via shared TableView; only visible rows mount (set maxHeight or fillHeight, not both). Footer and Add row sit outside the vertical scroll.
sortingHeader click cycles sort on the view (does not reorder form.values).
searchToolbar global search over row values — not Data Table column filters or JSON Logic. Editing rows stay visible while open (editing-row freeze).
selectionRow checkboxes; { batchDelete: true } docks Delete selected in the page footer batch bar (FooterToolbar, same pattern as list pages). Checkboxes are disabled while a row is editing.
columns{ manager: true } shows the column manager (hide/reorder/pin). Persist with tableCode.
footerColumn summary row (Sum / 合计) — Ant Design Table.Summary / Element Plus summary-row. Aligned with column widths and text-end on numeric columns. Aggregates the filtered full committed set (not the current offset page; drafts excluded). With fillHeight or virtual, the summary pins outside the vertical scrollport and stays inside shared horizontal scroll (Ant Summary fixed / Element Plus sticky footer). Without a height-constrained body, it stays an inline <tfoot>.
indexOptional read-only ordinal column. { mode: "view" } numbers the current view order (page slice or virtual window); { mode: "array" } uses the true array index.
rowActionsBuilt-in Edit/Duplicate/Delete (read mode) and Save/Cancel (edit mode). false hides both; { duplicate?: boolean, remove?: boolean, duplicatePosition?: "after" | "top" | "bottom" } toggles individually. Default both on; duplicatePosition defaults to "after" (built-in Duplicate only — Add still uses recordCreator.position).

Breaking: Duplicate Placement

Built-in Duplicate now inserts after the source row by default (array order). Previously it followed Add (recordCreator.position, typically the end). Set features.rowActions.duplicatePosition: "bottom" (or "top") to restore the old placement.

Keyboard

Excel-style cell navigation (default, no props). Matches AG Grid / spreadsheet expectations for operational grids.

KeyBehavior
↑ ↓ ← →Move focus to the adjacent editable cell (skips locked / hidden). In a single-line text field, ← → only leave the cell when the caret is at the start / end.
EnterFinish the current cell and move to the same column on the next row. In row edit mode, Enter also Saves the draft first (stays put if validation fails).
Shift+EnterSame column on the previous row (row mode: Save first).
EscInline: blur the control. Row editing: Cancel the draft.
TabUnchanged — browser / form tab order.

Open Select / Date / Combobox overlays keep their own arrow / Enter handling. Multiline textareas still use Enter for newlines.

Recipes — "I Need To…"

Each recipe links to a live demo. Copy the pattern, then adapt column keys and validators.

…require field B when field A has a value (same row)

Use field.reactions to toggle display / required, plus a validator that reads the full row.

→ Demo: Cell Validation Feedback

…show a header field when any table row matches a rule

Use parent effects + onFieldValueChange("lines.*.qty", …) or onFieldValueChange("lines", …) and form.setFieldState("headerField", …).

→ Demo: Cross-Row Header Effects

…save each row to the server before it appears "committed"

Row mode + editable.onSave / editable.onDelete. onSave may return the saved row (server id / etag). Duplicate is CREATE (meta.isNew). Reject the promise to keep the row editing with saveError.

→ Demo: Remote Persistence

…bulk-add rows from a catalog picker

toolbar.trailing button → form.query("lineItems").take() + field.push(…) or headless addRows([…]).

→ Demo: Row Mode — Operational Grid (Add from catalog)

…block submit while someone is mid-edit

Default in row mode. Use editable: { type: "single" } when only one draft may be open.

→ Demo: Single Edit Session

…validate on submit including hidden / paged rows

Parent form submit scans the full array. Rollup links call jumpToRow (clears search, scrolls virtual/offset view).

→ Demo: Submit Validation

…paint host Validate / server errors so they survive insert and remove

Use applyEditableArrayIssues with rowId + colKey. Never stash chrome on lines.N.* by index alone.

→ Demo: Identity-Keyed Host Validation · Painting Validation on Rows

…let users save an incomplete draft, then validate on final submit

editMode="inline" + a Save draft button that persists form.values without calling form.validate().

→ Demo: Inline Edit Mode

…lock approved rows but keep pending rows editable

rowEditable={(row) => row.status !== "approved"} + rowActionsPolicy to hide Remove when the line can never be deleted.

→ Demo: Row Edit Gates

…lock one column on a row while siblings stay editable

field.readOnly={(row) => row.status === "approved"} on that column only.

→ Demo: Mixed Column Gates

…show 50+ rows without lag

features.view: { mode: "virtual", maxHeight: "min(360px, 50vh)" } — or offset paging when users expect page numbers.

→ Demo: Virtual Scroll

…fit 10+ columns without horizontal scroll

Mark infrequent fields with tier: "secondary"; enable expand={{ expandAll: true }} for toolbar expand-all. Requires getRowId. Non-virtual view modes only in v1.

→ Demo: Wide Rows: Column Tiers & Row Detail

…nest child rows under a parent (master–detail)

Pass nested={{ path, columns, recordCreator }}. Children live on parent[path][] (Model A). Derived parent remaining / Σ use read-only accessor / footer — no kit rollup mutator. Do not combine with tier: "secondary".

→ Demo: Nested Sub-Rows (Master–Detail)

…group consecutive rows under a band

Pass Table rowGrouping (getGroupKey + renderBand). Editable Table forwards it to the matrix body; bands are not ArrayField rows. stickyBands defaults to true (band sticks under the header while group leaves scroll in a maxHeight / fill scrollport). Unsupported with view.mode: "virtual" in v1 — use a scrollport when you need bands. Read Table docs for when sticky helps, DOM cost (one mini-table per group), and stickyBands: false.

→ See Table — Row Grouping

…keep all columns in the grid (scroll + pin)

Declare pinned: "left" on identity columns (e.g. SKU). Index / Actions already pin. Horizontal scroll is expected — freeze the columns operators must keep in view. Use the column manager to hide low-value fields.

→ Demo: Wide Columns: Flat Matrix (Scroll + Pin)

…read-only review with sort and totals

readOnly on the table — sorting and features.footer still work.

→ Demo: Read-Only Review

…wrap formatted cells or paint a cell

renderCell wraps the already-formatted display node (not the editing control). cellClassName paints the cell. For attention, wrap with CellCriticalityValue inside renderCell — do not fake a ring.

Extra Copy On Display Cells · Cell Wash Without A Ring · composed: Change Review · browse Table: Cell Customization

…custom layout (drawer, split pane, no bundled chrome)

useEditableTable + EditableTableContext + compose TableView yourself.

→ Demo: Headless Usage

Troubleshooting

SymptomLikely causeFix
I don't know where to startPage is longQuick StartExamples Level 1
Header total does not update while I type in a rowRow mode keeps drafts out of form.valuesMove the total to parent effects on onFieldValueChange("lineItems", …) (fires after Save), or use editMode="inline" for live values
Manager approval / freight rule never firesCross-row logic in field.reactions on a table cellMove to parent effects or header FormField reactions that query("lineItems")
Error shows on first keystrokeCustom validator with triggerType: "onInput" on a fresh fieldUse default blur timing; cell chrome already punishes late
Focus jumps out of cell on first keystrokeA custom cell or wrapper changes React type when errors or table options updateBuilt-in field, display, index, and action cells use stable module-level component types. Keep custom TanStack cell component references stable too
Value-state message steals focusCustom popover without onOpenAutoFocus preventDefaultBuilt-in cells use non-modal PopoverAnchor + prevent auto-focus — keep focus in the input
Submit says row 5 is invalid but I don't see itSearch or offset paging hid the rowClick the rollup jump link, or clear search — submit always scans the full array
"Finish the current row first" on submitA row is still in edit mode (draft not saved)Save or Cancel every open row before submit
onSave never runseditMode="inline"Remote hooks are row-mode only — use parent submit or inline draft save
Column manager resets on refreshMissing tableCodePass a stable tableCode string per table instance
Footer total ignores my editsEditing a draft row in row modeFooter aggregates committed rows only — Save first
Duplicate creates a weird rowUnstable getRowIdReturn a real id from your row object; do not rely on array index
New row vanishes on CancelExpected — cache draft discardedUse recordCreator.newRecordType: "dataSource" only if you intend immediate array push

Compared to Ant Design Pro EditableProTable

Ant Design ProEditable Table
value / onChange on the tableRows live in Formily form.values under name
recordCreatorPropsrecordCreator
editable.type single / multipleeditable.type — same mental model
onSave / onDelete per roweditable.onSave / editable.onDelete
editableKeys controllededitable.editableKeys + onEditableChange
valueType per columnColumn kind + optional field
formItemProps rulesfield.validator, field.required, field.reactions
ProComponents editable: false on columnOmit field, or field.readPretty when reactions need a Field
Search / toolbar slotsfeatures.search, toolbar.leading / trailing
No first-class parent formNative <Form> submit + header FormFields beside the table

Editable Table intentionally matches Pro's operational grid ergonomics (read at rest, explicit Save, single-edit gate) while staying Formily-native for header fields, effects, and one-shot form submit.

Glossary

TermMeaning
Committed rowA row in form.values at name — what submit validates and what footers sum
Draft rowRow-mode only: transient editor form until Save/Cancel
ViewWhat you see after sort, search, and paging/virtual slice — may differ from array order
Pending editAt least one draft row open — blocks parent submit in row mode
RollupEditableErrorRollup list under the table with jump-to-row links
kindRead formatter key (like Pro valueType)
Cache rowNew row from Add that exists only in the edit session until Save

Headless Usage

useEditableTable is the view-model inside Formily ArrayField: rows, sort, pagination, validation, and addRow / addRows. The demo uses native <button> for toolbar actions and TableView / EditablePagination as the data surface — not EditableTableToolbar or EditableTableAddRow.

Actions
SKU-0012
1 rows
Rows per page
"use client";

import { useMemo } from "react";
import { ArrayField, observer } from "@formily/react";

import { TableView } from "@/components/f-ui/table/table-parts/table-view";
import { EditablePagination } from "@/components/f-ui/editable-table/editable-table-parts/editable-pagination";
import { defineEditableColumns } from "@/components/f-ui/editable-table/define-editable-columns";
import {
  EditableTableContext,
  type EditableTableContextValue,
} from "@/components/f-ui/editable-table/editable-table-context";
import { EditableErrorRollup } from "@/components/f-ui/editable-table/editable-table-parts/editable-error-rollup";
import { useEditableTable } from "@/components/f-ui/editable-table/use-editable-table";
import { Input } from "@/components/f-ui/formily/connects/input";
import { NumberInput } from "@/components/f-ui/formily/connects/number-input";
import { Form } from "@/components/f-ui/formily/form";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { f } from "@/components/f-ui/field-types/catalog";

type LineItem = { id: string; sku: string; qty: number };

/** Templates only — mint a fresh id on each add so catalog can be reused. */
const CATALOG_LINES: Omit<LineItem, "id">[] = [
  { sku: "SKU-100", qty: 10 },
  { sku: "SKU-200", qty: 5 },
];

const columns = defineEditableColumns<LineItem>({
  sku: {
    ...f.text({ label: "SKU" }),
    field: { component: [Input], required: true },
  },
  qty: {
    ...f.number({
      label: "Qty",
    }),
    size: 90,
    field: {
      component: [NumberInput, { surface: "tableCell" }],
      required: true,
      validator: (v) => (Number(v) > 0 ? undefined : "needs > 0"),
    },
  },
});

const HeadlessTable = observer(function HeadlessTable() {
  const fat = useEditableTable<LineItem>({
    name: "lineItems",
    tableCode: "demo-order-lines-headless",
    columns,
    getRowId: (r) => r.id,
    features: { view: { mode: "offset", pageSize: 5 } },
  });

  const ctx = useMemo<EditableTableContextValue<LineItem>>(
    () => ({
      name: "lineItems",
      editMode: fat.editMode,
      readOnly: false,
      columns: fat.columns,
      getRowId: fat.getRowId,
      indexOf: fat.indexOf,
      isRowEditable: fat.isRowEditable,
      isNewRow: fat.isNewRow,
      removeRow: fat.removeRow,
      duplicateRow: fat.duplicateRow,
      getRowOrdinal: fat.getRowOrdinal,
      rowActionsFeature: fat.rowActionsFeature,
      errorsStore: fat.errorsStore,
      hasRowErrors: (rowId) => fat.errorsStore.getSnapshot().has(rowId),
      isEditing: () => false,
      editType: "single",
      editorFormFor: () => undefined,
      editorEntryFor: () => undefined,
      startEdit: () => {},
      autoFocusCell: null,
      clearAutoFocusCell: () => {},
      saveRow: () => {},
      cancelRow: () => {},
      deleteRow: () => {},
      rowStatus: () => ({ kind: "idle" }),
      editingKeys: fat.editableKeys,
      expandEnabled: fat.expandEnabled,
      isRowExpanded: fat.expand.isExpanded,
      toggleRowExpanded: fat.expand.toggle,
      rowDetailAriaLabel: "Toggle row details",
    }),
    [fat],
  );

  return (
    <EditableTableContext.Provider value={ctx as EditableTableContextValue}>
      <div>
        <button
          type="button"
          onClick={() =>
            fat.addRows(
              CATALOG_LINES.map((line) => ({ ...line, id: crypto.randomUUID() })),
            )
          }
        >
          Add from catalog
        </button>
        <TableView<LineItem>
          table={fat.table}
          sort={fat.sort}
          onSortColumn={fat.onSortColumn}
          getRowClassName={(row) => fat.getRowClassName(row)}
        />
        <EditablePagination
          pagination={fat.pagination}
          pageSizeOptions={fat.pageSizeOptions}
          onChange={fat.onPageChange}
        />
        <button
          type="button"
          onClick={() =>
            fat.addRow({ id: crypto.randomUUID(), sku: "", qty: 1 })
          }
        >
          Add line
        </button>
        <EditableErrorRollup
          items={fat.rollupItems}
          onJump={(rowId, colKey) => {
            void fat.navigateToError({ rowId, colKey });
          }}
        />
      </div>
    </EditableTableContext.Provider>
  );
});

export function EditableTableHeadlessDemo() {
  const form = useMemo(
    () =>
      createForm<{ lineItems: LineItem[] }>({
        initialValues: {
          lineItems: [{ id: "a", sku: "SKU-001", qty: 2 }],
        },
      }),
    [],
  );

  return (
    <Form form={form} onSubmit={async () => undefined}>
      <ArrayField name="lineItems">{() => <HeadlessTable />}</ArrayField>
    </Form>
  );
}

Composition

EditableTable
├── FormilyArrayField
├── EditableTableToolbar (add / search / batch delete / column manager / toolbar slots)
├── EditableTableViewStatus (virtual view — row count above scrollport)
├── TableView (sort headers, resize, optional virtual scrollport + tableFooter)
├── EditablePagination (when features.view.mode is offset)
└── EditableErrorRollup

Read cells (EditableFieldCell) render formatted display text — no inputs. Editing cells (EditableEditCell in row mode, EditableInlineCell in inline mode) mount Formily fields. Row actions (EditableRowActions) branch between read mode (Edit/Duplicate/Delete) and edit mode (Save/Cancel).

Form Table vs. Browse Table

f-ui ships two table editing paradigms. They share tokens but render differently on purpose:

ParadigmComponentEditing modelCell control
Form tableEditableTableRow: click-to-edit, commit-gated draft. Inline: always live.Borderless (surface="tableCell") — grid lines carry structure
Browse tableDataTable + InlineEditPlain text at rest; click toggles one cellBordered (compact) — border signals edit mode

Editable Table primary cells use borderless controls (surface="tableCell") — grid lines carry structure. Editable primary cells show a subtle field box affordance so inputs read as editable at rest. Secondary-tier fields in the row-detail panel use panel surface styling (no double border). Composite controls (CurrencyInput, Select) opt into the borderless surface via acceptsTableCellSurface on their Formily connect. Cell-attention tokens (editableTableCellErrorClass, editableTableCellWarningClass, editableTableCellLockedClass) live in editable-table/lib/table-cell-chrome.ts.

Validation

Panel-form recipes (reveal modes, summaries, async uniqueness, server issue mapping) live on Form Validation. This page owns table cell chrome, the region rollup, and identity-keyed host paint.

Validation is two-tier. Timing follows mainstream inline-form guidance (reward early, punish late): do not flash errors while the user is still typing into a fresh field; do reveal errors on blur and keep them live while fixing.

TierWhenWhat the user sees
Live (cell)Blur on an invalid editable field, or after a submit attemptValue-state ring (errors) or ring + tint (warnings); message on focus (+ aria-description when blurred). Never a full-cell destructive wash on a field box.
Submit-time (data)Parent form submit or validateAll()Rollup below the table with jump links; scans rows hidden by search or paging
Host-appliedapplyEditableArrayIssuesSame rollup (errors and warnings). Cell chrome only if pattern === "editable"
Object readinessHost Validate / Run check / SubmitPrefer Message Popover for multi-region issues — not hover tooltips
Per-row SaveRow mode only, before commitEditor form validates; invalid rows stay editing with saveError on remote failure

Pending row edits block parent submit until every row is Saved or Cancelled. Inline mode has no pending-edit gate.

Per-row Save validates the editor form before committing. Cancel discards the draft without touching the committed row.

Painting Validation on Rows

Wire paths stay index-shaped (lines.1.qty) — that is Formily ArrayField. Ownership must be the stable getRowId.

DoDon’t
applyEditableArrayIssues(form, { arrayPath: "lines", issues: [{ rowId, colKey, message, source }] })field.selfErrors = ["…"] once at lines.1.qty
clearEditableArrayIssues(form, { arrayPath: "lines" }) before a full re-Validate replaceAssume React key={row.id} moves Formily feedback
Keep getRowId stable (or accept a new function identity — the kit holds it in a ref)Path-only applyFormIssues under a mounted EditableTable for cell paint

Import helpers from @/components/f-ui/editable-table/lib/editable-array-issues (no package barrel).

What the kit does: on ArrayField structure change (insert / remove / reorder / whole-array setValues), Editable Table clears sticky feedback under that array and rematerializes the identity bag onto current indices in the same turn — so Validate chrome does not jump to a neighbor line.

Dev warning: applyFormIssues with a path under a mounted Editable Table logs a console warn in development and points at this bridge.

Nested tables: child issue bags use absolute paths that include the parent index (e.g. lines.0.claims). Nested issues survive parent edits only while that parent row’s index stays stable; a parent insert/reorder before that row remounts the child at a new path — re-apply nested issues. Flat tables are fully covered.

→ Interactive proof: Identity-Keyed Host Validation

Field Semantics

Lock, hide, and highlight map onto Formily. Following Ant Design Pro's EditableProTable, browse rows render read display only (no mounted field); a field mounts when the row enters edit (row mode) or always (inline mode). Within an editing row, an editable column mounts at pattern="editable", a session-locked fillable column at pattern="readOnly" (same control), and a computed/display column at pattern="readPretty" (or omit field).

QuestionPattern
Can this role change it in this session?editable
Could it be a control here, but is locked right now (permission, dependency, concurrent lock, in-flight save)?readOnly
Is it never a control here (computed, subtotal, generated ID, audit, workflow status), or is the surface in display mode?readPretty (or omit field)
Is it inapplicable until a dependency is satisfied?disabled

Fiori: Read Only — do not use if a UI element can never become editable, or if the page (or part of it) is in display mode. Display Only — do not use if a page is editable and a UI element is currently not editable.

NeedUse
Whole table Display (inbox until Unlock)field.readPretty on applicant columns; table readOnly only to hide add/actions
Whole table locked EditreadOnly — mounted controls, not pretty
Whole row lockedrowEditable={(row) => …} — no Edit button
Only some columns locked on an editable rowfield.readOnly={(row) => …} — the cell is marked automatically; add field.lockReason for the why
Always-display status / derived valueColumn with no field (f.enum({ render: "status" }), accessor)
Within-row derived value while editingfield.readPretty + field.reactions (or omit field when no Field is needed)
Cross-row derived valueParent-form effects on committed array changes (post-save in row mode; live in inline)
Hide / compute / cross-field rulesfield.reactions (Formily display / required / value)
Fix this value nowAutomatic value-state ring on editable cells only (message on focus)
Host / backend cell messageapplyEditableArrayIssues — rollup always; cell chrome only if the field is editable
Display-row attentiongetRowHighlight leading-edge bar
Display-cell attentionrenderCell + CellCriticalityValue (colour + icon; host reason)
Non-severity paintcellClassName — first-class layer 4; never rebuild the ring with it
Disable built-in duplicate/remove per rowrowActionsPolicy — locked rows hide built-in actions; policy fine-tunes editable rows

Row lock vs. policy: rowEditable controls whether cells render as read text or allow Edit. rowActionsPolicy fine-tunes duplicate/remove on rows that remain editable.

Every read surface formats through one renderer keyed by column kind (the f-ui analogue of Ant Design Pro's valueType).

API Reference

Props

nameFormily ArrayField path (e.g. "lineItems").
columnsdefineEditableColumns map — display kind + optional field, renderCell, cellClassName, and footer.
getRowIdStable row id for selection, duplicate, and error rollup. Defaults to an internal key on the row object.
rowFilter(row) => boolean — optional view predicate (e.g. wire QueryFilter onFinish here). Applied before toolbar search; does not mutate form.values. Keep the function identity stable (useCallback) — a new reference resets offset page to 1. jumpToRow / error jump pages against the filtered ordered list when the target is still visible; if QueryFilter hides the row, the table suspends rowFilter until the next filter apply so the row can be revealed.
readOnlyWhen true, the whole table is read-only (default false).
rowEditable(row, index) => boolean — per-row edit gate (default: editable when !readOnly). Locked rows show read text and no Edit button.
getRowClassName(row, meta) => string — row styling seam. meta: { editable, hasErrors, isNew }.
getRowHighlight(row) => "error" | "warning" | "success" | "new" | "none" — display-mode leading-edge bar. Overridden by editing / new / busy washes.
tableCodePersistence key for column order/visibility (column manager).
features{ view?, sorting?, search?, selection?, columns?, footer?, index?, rowActions? } — see table above. view defaults to { mode: "all" }. offset accepts { pageSize?: number, pageSizeOptions?: … } (default page size 20). virtual accepts { maxHeight?, fillHeight?, overscan?, estimateRowHeight?, tableScrollbar? } — set maxHeight or fillHeight, not both. search is global search only. columns: { manager: true } requires tableCode for localStorage persistence. index defaults off; pass { mode?: "view" | "array", label?, size? }. rowActions defaults both duplicate and remove on; optional duplicatePosition?: "after" | "top" | "bottom" (default "after") applies to built-in Duplicate only — Add still uses recordCreator.position.
recordCreator{ position?: "top" | "bottom", newRecordType?: "cache" | "dataSource", record?: () => Partial<TData>, maxLength?: number } — toolbar add button; new rows open in edit mode as cache drafts until Save.
variant"default" | "embedded" (default "default"). "embedded" turns off grid chrome (search/sort/batch-delete/column manager) — use inside Form Page / Modal Form. Summary footer is opt-in via features.footer.
editMode"row" | "inline" (default "row"). "inline" = always-live cells bound to form.values, no per-row Save, no submit gate. Row-only editable props are ignored in inline mode.
editable{ type?: "single" | "multiple", editableKeys?, onEditableChange?, onSave?, onDelete?, onCancel?, onOperationError? } — remote persistence hooks and controlled edit keys. onSave returns Promise<void | TData>: returning the row replaces the client draft (id swap); undefined keeps today's draft commit. Rejecting Save/Delete keeps a retryable saveError/deleteError row state and calls onOperationError({ kind, rowId, error }).
rowActions(row) => RowActionItem[] — custom actions per row (e.g. view detail), merged with the built-in duplicate/remove and collapsed into the overflow menu as needed. Each item: { key, label, icon?, onClick, disabled?, destructive? }.
rowActionsPolicy(row, meta) => { duplicate?, remove? } — per-row built-in action controls. Each action accepts { hidden?: boolean, disabled?: boolean | string }. hidden when this row can never run the op; string disabled when the block is temporary and obvious (tooltip / aria-description).
removeRowConfirmfalse deletes immediately; otherwise a RowConfirmPopover (Popconfirm) confirms single-row remove. Title/description accept static strings or (ctx) => string with { row, rowId, index }.
batchDeleteConfirmConfirm copy for footer batch delete when features.selection.batchDelete is on. Uses an AlertDialog; context is { count }. Pass false to skip confirmation.
footerActionsPersistent right-side actions in the page footer bar (e.g. Submit). Shown with batch delete in FooterToolbar.
footerExtraLeft-side footer chrome when the bar is visible and nothing is selected.
toolbar{ leading?, trailing? } — inject custom controls beside the built-in search / column-manager toolbar (e.g. a bulk-add picker that calls addRows).
expand{ enabled?, defaultExpanded?, autoExpandOnError?, expandAll? } — row-detail expand behavior. Auto-enables when any column has tier: "secondary". expandAll: true (default) shows toolbar Expand all / Collapse all. Requires getRowId. Non-virtual view modes only. Mutually exclusive with nested.
nested{ path, columns, recordCreator?, getChildRowId?, rowExpandable?, empty?, features?, autoExpandOnError?, expandAll?, defaultExpanded? } — master–detail child Editable Table under each parent row. path is the relative array key (e.g. "claims"). Mutually exclusive with tier: "secondary" / expand. Max two levels. Non-virtual view modes only. See Nested Sub-Rows (Master–Detail).
rowGroupingForwards Table rowGrouping (getGroupKey + renderBand + optional stickyBands) onto the non-virtual body. Bands are view chrome — not ArrayField rows. Unsupported with features.view.mode: "virtual" in v1 (bands ignored; dev error). Sticky / perf notes: Table — Row Grouping.

Slots

EditableTable does not expose classNames in v1. Style rows via getRowClassName, or compose headless parts.

SlotPurpose
toolbar.leadingContent before the add button (e.g. bulk-add from a picker dialog).
toolbar.trailingContent after search / batch delete.
rowActions(row)Per-row actions alongside duplicate and remove.
EditableRowActionsPublic row-actions cell (headless). Reads EditableTableContext; renders custom rowActions, Edit/Duplicate/Delete (read) or Save/Cancel (edit). Single-row remove uses RowConfirmPopover when removeRowConfirm is set; batch delete uses AlertDialog via the toolbar.

Hook

useEditableTable<TData>(options) returns:

tableTanStack Table instance over the current view rows (page slice or full ordered set in virtual mode).
sort, onSortColumnView sort state — pass to TableView.
pagination, onPageChangeClient offset paging over the filtered/sorted view (inert when view.mode is not offset).
viewMode, virtualConfigResolved view mode; virtualConfig is present when view.mode is "virtual".
search, onSearchChangeGlobal search term when features.search is on.
columnManagerColumn manager UI state when features.columns enables it.
rowsCurrent committed array value from Formily.
footerRowsFiltered full committed row set for footer aggregators (ignores offset paging and drafts).
compiled{ tableSchema, validators, footers }.
isRowEditable, isNewRowRow editability and new-row detection by stable id.
getRowClassNameResolved row class (error, new, muted, plus custom).
getRowOrdinal, rowActionsFeatureIndex column ordinal and resolved built-in action toggles — pass into headless context when composing EditableRowActions.
editableKeys, isEditing, editTypeCurrent editing-row state.
startEdit, saveRow, cancelRow, deleteRow, finishEditPer-row editing state machine. finishEdit disposes a session without committing (headless cleanup).
editorFormFor, editorEntryForAccess the per-row editor form and metadata.
rowStatus{ kind: "idle" | "saving" | "saveError" | "deleting" | "deleteError", message? } per row.
hasPendingEdits, blockedMessageVisibleSubmit gate helpers.
addRow, addRows, duplicateRow, removeRow, removeSelectedArray mutations by stable id. addRows appends N rows (bulk picker). duplicateRow clones into a cache draft until save. removeRow removes from the array and clears editing state when headless.
validateAll, jumpToRow, focusFirstError, navigateToError, errorsSubmit-time validation and reveal/focus navigation. errors is a readonly snapshot; never mutate it in place.

Built-in strings resolve through useEditableTableI18n() (form-domain keys) and useTableI18n() (shared view keys) — see Shared View Tier.

Identity Issue Helpers

Import from @/components/f-ui/editable-table/lib/editable-array-issues (deep path — no barrel).

applyEditableArrayIssues(form, { arrayPath, issues, mode? })Store identity-keyed issues (rowId + colKey + message + optional severity / source) and paint current Formily indices. Default mode: "replace" replaces issues for the sources present in the payload (same ergonomics as Formily applyFormIssues).
clearEditableArrayIssues(form, { arrayPath, rowId?, colKey?, source? })Clear bag entries (and rematerialize / clear Formily paint) for the filter.
getEditableArrayIssues(form, arrayPath)Read the current identity bag for that ArrayField path.

EditableArrayIssue: { rowId: string; colKey: string; message: string; severity?: "error" \| "warning"; source?: "server" \| "submit" \| "system" }. Default source for host Validate is system.

Requires a mounted Editable Table for that arrayPath so the kit can resolve getRowId → index. See Painting Validation on Rows.

On this page

When to UseWhen Not to UseInstallingUsageQuick Start (5 Minutes)ExamplesRow Mode — Operational GridSingle Edit SessionInline Edit ModeTyped Field ColumnsWide Rows: Column Tiers & Row DetailWide Columns: Flat Matrix (Scroll + Pin)Cell Validation FeedbackIdentity-Keyed Host ValidationCross-Row Header EffectsSubmit ValidationComplete Order EntryRemote PersistenceRow Edit GatesMixed Column GatesMixed PatternVirtual ScrollLarge Dataset (1k–2k Rows)Read-Only ReviewPattern BoundaryDisplay Vs EditSeverity RoutingExtra Copy On Display CellsCell Wash Without A RingChange ReviewLock ReasonHeadless Usage (Specialized)Define Your ColumnsCell CustomizationNested Sub-Rows (Master–Detail)Core ConceptsShared View TierInternationalizationTwo Data PlanesChoosing an Edit ModeWhat features Actually DoesKeyboardRecipes — "I Need To…"…require field B when field A has a value (same row)…show a header field when any table row matches a rule…save each row to the server before it appears "committed"…bulk-add rows from a catalog picker…block submit while someone is mid-edit…validate on submit including hidden / paged rows…paint host Validate / server errors so they survive insert and remove…let users save an incomplete draft, then validate on final submit…lock approved rows but keep pending rows editable…lock one column on a row while siblings stay editable…show 50+ rows without lag…fit 10+ columns without horizontal scroll…nest child rows under a parent (master–detail)…group consecutive rows under a band…keep all columns in the grid (scroll + pin)…read-only review with sort and totals…wrap formatted cells or paint a cell…custom layout (drawer, split pane, no bundled chrome)TroubleshootingCompared to Ant Design Pro EditableProTableGlossaryHeadless UsageCompositionForm Table vs. Browse TableValidationPainting Validation on RowsField SemanticsAPI ReferencePropsSlotsHookIdentity Issue Helpers