Skip to main content
This guide covers changes to the beta DataTable component. These APIs may continue to evolve before the stable release.

Overview

This guide continues the work started in Column Types and EditConfig, which introduced the editConfig property. Custom edit mode is a peer to the existing text, number, boolean, select, and multiselect edit experiences, but it is designed for cells whose committed value is an object rather than a scalar. The DataTable now supports a dedicated custom edit mode for object-backed cells:
  • editConfig.mode: "custom" — Opens a multi-field editor surface for the cell
  • renderEditor — Renders your custom editor UI with row context and a typed controller
  • onCommit — Receives the final committed object value when the editor closes normally
  • onDraftUpdate — Optional advanced callback for consumers that need every local draft mutation
  • validateDraft / blockOnValidationError / onRequestClose / surface — Optional hooks for validation, close policy, and surface presentation
Custom mode keeps the canonical cell value intact. You do not have to flatten an address, form state, or other object into a string just to make the cell editable.

Read mode vs edit mode

Custom mode intentionally separates the read and edit paths:
  • renderCell remains the read-mode renderer for the cell.
  • getCellText is the preferred optional plain-text extractor for sorting when your read view is JSX or multiline.
  • getReadRenderResult is the advanced optional read hook when you want one hook to return both rendered read content and a sortable read string together.
  • renderEditor is the edit-mode renderer for the anchored editor surface.
This matters for object-backed values because the display version of a cell is often formatted text or JSX, while the editable version is a small form.
createColumn("address", {
  headerLabel: "Address",
  renderCell: (value) => renderAddress(value),
  getCellText: (value) => formatAddress(value),
  editConfig: {
    mode: "custom",
    onCommit: (value, rowId) => saveAddress(value, rowId),
    renderEditor: ({ controller }) => {
      return <AddressEditor controller={controller} />;
    },
  },
});
Use getCellText by default when you already have a separate renderCell and just need a plain sortable string. Reach for getReadRenderResult only when you need one hook to produce both the rendered read content and the raw sortable string together.

Editor context and controller API

renderEditor receives a context object with:
  • row — the full row data
  • rowId — the row identifier as a string
  • value — the committed canonical value for the cell
  • controller — typed draft, validation, focus, and lifecycle control for the editor
The controller surface includes:
  • draftValue / initialValue
  • isDirty / changedFields
  • validation
  • setDraftValue
  • setDraftField
  • handleSubmit
  • submit
  • discard
  • requestClose(reason)
  • setInitialFocus(focus)
When you want Enter-to-submit behavior inside a custom editor, render the editor contents inside a <form onSubmit={controller.handleSubmit}>. The DataTable routes Enter through the nearest enclosing form and calls requestSubmit() on that form. If your custom editor does not render a form, Enter does not implicitly submit the draft. Textareas and other controls that need Enter for their own interaction should continue to manage that key normally.

Close requests and validation

Custom close behavior is explicit:
  • requestClose("submit"), outside click, close button, and programmatic submit commit the current draft by default.
  • requestClose("escape") and pressing Escape discard the draft by default.
  • Pressing Enter inside a custom editor submits only when focus is inside a form wired with onSubmit={controller.handleSubmit}.
  • onRequestClose(request) can return false to keep the editor open and block the default behavior.
Validation is informational by default. validateDraft can return formError and fieldErrors, but those values do not block closing unless blockOnValidationError is true or onRequestClose intercepts the request. Surface presentation options now live under editConfig.surface.

Example: custom address editor

This example keeps the address as an object, renders a multiline read view, previews validation inside the editor, and commits updates at the row level when the surface closes. Because the editor is wrapped in <form onSubmit={controller.handleSubmit}>, pressing Enter inside a single-line field submits through that form.
import { Button } from "@servicetitan/anvil2";
import {
  DataTable,
  Flex,
  TextField,
  Textarea,
  createColumnHelper,
  type TableRow,
} from "@servicetitan/anvil2/beta";
import { useState } from "react";

type AddressValue = {
  street1: string;
  street2: string;
  city: string;
  state: string;
  postalCode: string;
  deliveryNotes: string;
};

type RowData = {
  id: string;
  customer: string;
  address: AddressValue;
};

const formatAddress = (value: AddressValue) =>
  [
    [value.street1, value.street2].filter(Boolean).join(", "),
    [value.city, [value.state, value.postalCode].filter(Boolean).join(" ")]
      .filter(Boolean)
      .join(", "),
    value.deliveryNotes ? `Notes: ${value.deliveryNotes}` : undefined,
  ]
    .filter(Boolean)
    .join("\n");

const initialData: TableRow<RowData>[] = [
  {
    id: "1",
    customer: "Bayside Coffee",
    address: {
      street1: "188 Market Street",
      street2: "Suite 400",
      city: "San Francisco",
      state: "CA",
      postalCode: "94105",
      deliveryNotes: "Leave with front desk before 3 PM.",
    },
  },
];

function App() {
  const [data, setData] = useState(initialData);
  const createColumn = createColumnHelper<RowData>();

  const columns = [
    createColumn("customer", {
      headerLabel: "Customer",
      sortable: true,
    }),
    createColumn("address", {
      headerLabel: "Address",
      minWidth: 280,
      sortable: true,
      renderCell: (value) => (
        <span style={{ whiteSpace: "pre-line" }}>{formatAddress(value)}</span>
      ),
      getReadRenderResult: (value) => ({
        content: (
          <span style={{ whiteSpace: "pre-line" }}>{formatAddress(value)}</span>
        ),
        rawString: formatAddress(value),
      }),
      editConfig: {
        mode: "custom",
        surface: {
          title: "Edit address",
          width: 360,
          maxHeight: 480,
          closeButtonLabel: "Save address changes",
        },
        blockOnValidationError: true,
        validateDraft: (draftValue) => ({
          formError: draftValue.street1 ? undefined : "Street address is required.",
          fieldErrors: draftValue.street1
            ? undefined
            : { street1: "Enter a street address." },
        }),
        onCommit: (value, rowId) => {
          setData((prev) =>
            prev.map((row) =>
              row.id === rowId ? { ...row, address: value } : row,
            ),
          );
        },
        renderEditor: ({ controller, rowId }) => {
          const changedFieldsLabel = controller.changedFields
            .map(String)
            .join(", ");

          return (
            // Wrap custom editor contents in a form to enable Enter-to-submit.
            <form onSubmit={controller.handleSubmit}>
              <Flex direction="column" gap={12}>
                <TextField
                  ref={(element) => {
                    controller.setInitialFocus(
                      element ? () => element.focus() : null,
                    );
                  }}
                  label="Street address"
                  value={controller.draftValue.street1}
                  onChange={(event) =>
                    controller.setDraftField("street1", event.target.value)
                  }
                  error={controller.validation.fieldErrors?.street1}
                />
                <TextField
                  label="City"
                  value={controller.draftValue.city}
                  onChange={(event) =>
                    controller.setDraftField("city", event.target.value)
                  }
                />
                <Flex gap={12}>
                  <TextField
                    label="State"
                    maxLength={2}
                    value={controller.draftValue.state}
                    onChange={(event) =>
                      controller.setDraftField(
                        "state",
                        event.target.value.toUpperCase(),
                      )
                    }
                  />
                  <TextField
                    label="Postal code"
                    inputMode="numeric"
                    value={controller.draftValue.postalCode}
                    onChange={(event) =>
                      controller.setDraftField(
                        "postalCode",
                        event.target.value.replace(/[^\d]/g, "").slice(0, 5),
                      )
                    }
                  />
                </Flex>
                <Textarea
                  label="Delivery notes"
                  autoHeight
                  minRows={3}
                  value={controller.draftValue.deliveryNotes}
                  onChange={(event) =>
                    controller.setDraftField("deliveryNotes", event.target.value)
                  }
                />

                {controller.validation.formError ? (
                  <div>{controller.validation.formError}</div>
                ) : null}

                {controller.isDirty ? (
                  <div>
                    Row {rowId} changed fields: {changedFieldsLabel || "none"}
                  </div>
                ) : null}

                <Flex gap={8}>
                  <Button type="submit">Save</Button>
                  <Button type="button" onClick={controller.discard}>
                    Discard
                  </Button>
                </Flex>
              </Flex>
            </form>
          );
        },
      },
    }),
  ];

  return <DataTable data={data} columns={columns} />;
}

Advanced: onDraftUpdate

onDraftUpdate is optional. Use it when you need draft-side telemetry, live preview, or external validation state on every local edit. It is not required for the normal custom editing flow.
editConfig: {
  mode: "custom",
  onDraftUpdate: (value, rowId) => {
    syncDraftPreview(value, rowId);
  },
  onCommit: (value, rowId) => saveAddress(value, rowId),
  renderEditor: ({ controller }) => <AddressEditor controller={controller} />,
}

Focus management with setInitialFocus

Use setInitialFocus to let the table focus one preferred field when the custom editor opens:
renderEditor: ({ controller }) => {
  return (
    <TextField
      ref={(element) => {
        controller.setInitialFocus(element ? () => element.focus() : null);
      }}
      label="Street"
    />
  );
};
Register a single preferred focus callback. Passing a new callback replaces the old one, and passing null clears it.
Last modified on April 24, 2026