Skip to main content
Beta FeatureThis feature is currently in beta, and needs to be imported from @servicetitan/anvil2/beta.While we hope to minimize breaking changes, they may occur due to feedback we receive or other improvements. These will always be documented in the changelog and communicated in Slack.Please reach out in the #ask-designsystem channel with any questions or feedback!
Known limitations:
  • Toolbars are not integrated with the DataTable yet
  • Row grouping is not yet supported
  • Limited default formatting and editable cell options are available
  • Virtualization is not currently supported

Common Examples

import { DataTable, createColumnHelper, currencyFormatter, type TableRow } from "@servicetitan/anvil2/beta";

type OrderData = {
  id: string;
  customer: string;
  amount: number;
  status: "pending" | "shipped" | "completed";
  date: string;
};

const columnHelper = createColumnHelper<OrderData>();

const columns = [
  columnHelper("customer", {
    headerLabel: "Customer",
    sortable: true,
  }),
  columnHelper("amount", {
    headerLabel: "Amount",
    sortable: true,
    renderCell: currencyFormatter,
  }),
  columnHelper("status", {
    headerLabel: "Status",
    sortable: true,
    editMode: "select",
    onChange: (value, rowIndex) => {
      console.log(value, rowIndex);
    },
  }),
  columnHelper("date", {
    headerLabel: "Order Date",
    sortable: true,
  }),
];

const data: TableRow<OrderData>[] = [
  { id: "1", customer: "Acme Corp", amount: 1250.00, status: "completed", date: "2024-01-15" },
  { id: "2", customer: "TechStart", amount: 890.50, status: "shipped", date: "2024-01-16" },
  // ... more data
];

function ExampleComponent() {
  return <DataTable data={data} columns={columns} />;
}
The DataTable component provides a comprehensive solution for displaying complex tabular data with built-in support for sorting, pagination, row selection, and expansion.

Basic Usage

The DataTable component requires two props: data (row data) and columns (column definitions).
<DataTable data={rowData} columns={columnDefs} />
Type safety is built-in using TypeScript generics and a factory function used to create column definitions: createColumnHelper (below).

Working with columns

Defining data interface

To ensure type safety, a type or interface should be used that defines the columns in the table. The id property should always be included, as it is used internally for sorting, selection, etc.
interface TableColumns {
  id: string;
  customer_name: string;
  address: string;
  status: "active" | "inactive";
  amount_due: number;
  paid_percent: number;
}

Defining columns

To create column definitions, use the createColumnHelper factory function. This returns a function, which can then be used to create type-safe column definitions.
const createColumn = createColumnHelper<TableColumns>();
The createColumn function created above accepts two parameters:
  1. id: string | { group: string }. The string value must match one of the properties of the type interface provided to the factory function (id, customer_name, etc.). The object with the group parameter can accept any string, and is used to group column headers.
  2. column: configuration for column (see below for examples)
The DataTable.columns prop takes an array of column definitions, each created using the createColumn function created above.

Basic columns

const columns = [
  createColumn("id", {
    headerLabel: "Customer ID",
    sortable: true,
    resizable: true,
    minWidth: 100,
    maxWidth: 400,
  }),
  createColumn("customer_name", {
    headerLabel: "Name",
    sortable: true,
    resizable: true,
    minWidth: 150,
  }),
  createColumn("address", {
    headerLabel: "Address",
    resizable: true,
    minWidth: 200,
  }),
  createColumn("status", {
    headerLabel: "Status",
    resizable: true,
  }),
  createColumn("amount_due", {
    headerLabel: "Amount Due",
    minWidth: 100,
    maxWidth: 100,
  }),
  createColumn("paid_percent", {
    headerLabel: "Paid %",
    minWidth: 100,
    maxWidth: 100,
  }),
];

Grouped columns

const columns = [
  createColumn(
    { group: "customer_info" },
    {
      headerLabel: "Customer Info",
      columns: [
        createColumn("id", {
          headerLabel: "Customer ID",
          sortable: true,
          resizable: true,
          minWidth: 100,
          maxWidth: 400,
        }),
        createColumn("customer_name", {
          headerLabel: "Name",
          sortable: true,
          resizable: true,
          minWidth: 150,
        }),
        createColumn("address", {
          headerLabel: "Address",
          resizable: true,
          minWidth: 200,
        }),
        createColumn("status", {
          headerLabel: "Status",
          resizable: true,
        }),
      ],
    },
  ),
  createColumn(
    { group: "invoices" },
    {
      headerLabel: "Invoices",
      columns: [
        createColumn("amount_due", {
          headerLabel: "Amount Due",
          minWidth: 100,
          maxWidth: 100,
        }),
        createColumn("paid_percent", {
          headerLabel: "Paid %",
          minWidth: 100,
          maxWidth: 100,
        }),
      ],
    },
  ),
];

Pinned columns

Columns can be pinned to the left or right side of the table using the pinned property in the column options. Pinned columns have sticky position when the table is scrolled horizontally.
createColumn("amount_due", {
  headerLabel: "Amount Due",
  pinned: "left", // "left" | "right" | undefined
}),
Grouped columns cannot be pinned.

Sorting columns

If the sortable property is used in a column definition, it will attempt to sort the rows of the table when the column header is clicked.

Default sorting (boolean)

If the sortable property is set to true, clicking on the column header will attempt to sort the rows alphabetically based on the column data—first ascending, then descending, then unsorted. The sorting applies to the text or numeric value of the cell, regardless of what is rendered by the renderCell function, if provided.
createColumn("customer_name", {
  headerLabel: "Name",
  sortable: true,
}),

Custom sorting (function)

In some cases, especially with array values, it may be easier to control the sorting logic for a column. To do this, pass a function to the sortable property in the column definition:
createColumn("customer_name", {
  headerLabel: "Name",
  // sortable: (a, b) => number
  sortable: (nameA, nameB) => {
    // sort by last name
    const nameAWords = nameA.split(/\s+/);
    const lastNameA = nameAWords[nameAWords.length - 1];

    const nameBWords = nameB.split(/\s+/);
    const lastNameB = nameBWords[nameBWords.length - 1];

    return lastNameA.localeCompare(lastNameB);
  },
}),

Controlling sort (props)

Default sorted column
A column can be sorted by default using the DataTable.defaultSortedColumn prop. The onSort callback is called when the user clicks on a sortable column header cell, and passes the column id and whether the column is sorted descending. If the new sort state is unsorted, it will pass undefined instead.
<DataTable
  data={data}
  columns={primaryColumns}
  defaultSortedColumn={{
    id: "customer_name",
    desc: false,
  }}
  onSort={(sortedColumn) => {
    if (sortedColumn) {
      console.log(sortedColumn.id, sortedColumn.desc);
    } else {
      console.log("table is unsorted");
    }
  }}
/>
Controlled sort state
It is also possible to fully control the sorting state using the sortedColumn and onSort props of the DataTable.
import { useState } from "react";
import { DataTable, SortedColumn } from "@servicetitan/anvil2/beta";

export const SortedDataTable () => {
  const [sortedColumn, setSortedColumn] = useState<SortedColumn | undefined>({
    id: "customer_name",
    desc: false,
  });

  return (
    <DataTable
      data={data}
      columns={primaryColumns}
      onSort={setSortedColumn}
      sortedColumn={sortedColumn}
    />
  );
};

Working with cells

Formatting data in cells

The renderCell property in the createColumn options parameter enables custom rendering of cell data. If no renderCell function is provided, the cell will attempt to render the current value as plain text.Anvil2 provides utility functions for common render patterns. In most cases, these formatters should be used, but there may be edge cases that aren’t supported.

Default formatters

Currently, the following formatter functions are available:
  • chipsFormatter: show chips in cell, with optional truncation
  • currencyFormatter: show number as currency, with i18n options
  • percentFormatter: show number as percentage, with i18n options
In the future, we plan to have a robust set of formatters available for common cell formats to avoid inconsistencies. Feel free to contribute formatters that cover common use cases, and we would be happy to review! For complex or unproven use cases, we may suggest adding it to the anvil2-ext-common library instead. Learn more about this extended library here.
Import these functions from Anvil2 and apply them when defining the columns:
import {
  createColumnHelper,
  currencyFormatter,
  percentFormatter,
} from "@servicetitan/anvil2/beta";

const createColumn = createColumnHelper<TableColumns>();

const columns = [
  // ...other columns...
  createColumn("status", {
    headerLabel: "Status",
    renderCell: chipsFormatter,
  }),
  createColumn("amount_due", {
    headerLabel: "Amount Due",
    renderCell: currencyFormatter,
  }),
  createColumn("paid_percent", {
    headerLabel: "Paid %",
    renderCell: percentFormatter,
  }),
];
To change the internationalization options for the currencyFormatter and percentFormatter, pass an object as the second parameter of the functions:
createColumn("amount_due", {
  headerLabel: "Amount Due",
  renderCell: (value) => currencyFormatter(value, {
    locale: "en-AU",
    currency: "AUD",
  }),
}),
createColumn("paid_percent", {
  headerLabel: "Paid %",
  renderCell: (value) => percentFormatter(value, {
    locale: "en-AU",
  }),
}),
Other options are available for specific formatters:
FormatterOptions
currencyFormatterlocale: string, currency: string
percentFormatterlocale: string, decimals: number
chipsFormattertruncateChips: boolean

Chips formatter

The chipsFormatter works for both string and string[] values. The value parameter passed from the renderCell option will use the type based on the column it maps to in the interface used in the createColumnHelper function. In our example, since status has type "active" | "inactive, it will assume a string[], but in other cases it can be used with a single string.The chipsFormatter can be customized in two ways:
  1. Set the truncateChips option to true to automatically truncate chips when they overflow the cell boundaries.
  2. Map the value from the renderCell to an object that includes ChipProps (for adjusting the color, icon, etc.).
createColumn("status", {
  headerLabel: "Status",
  renderCell: (value) =>
    chipsFormatter(
      value?.map((status) => ({
        label: status.charAt(0).toUpperCase() + status.slice(1), // capitalize
        color: status === "active" ? "#007A4D" : "#e13212", // change chip color
      })),
      {
        truncateChips: true,
      }
    ),
}),

Custom formats

To customize the format of a cell in a way that isn’t supported by the default formatters, use the renderCell property, and return a ReactNode.
createColumn("status", {
  headerLabel: "Status",
  renderCell: (value) => value.map((status) => (
    <Text variant="body" size="small" subdued={status === "inactive"}>
      {status === "active" ? "Active" : "Inactive" }
    </Text>
  )),
}),
We cannot guarantee accessibility or proper keyboard navigation for custom rendered cells, so it is up to the implementor to build with caution and test screen readers and keyboard navigation.

Editable cells

The editMode and onChange properties can be used when defining columns to make data table cells editable. Currently, the supported options are "text", "select", and "multiselect". More options, such as "number" and "date", will be added in the future.The appearance of the cell content in the cell will still respect the renderCell property. Each type acts differently when in “edit mode”:
  • The editable text cell changes to a text input.
  • The editable select cell triggers a menu dropdown.
  • The editable multiselect cell triggers a popover with a search field and list view.
Edit mode is activated on a cell when the user clicks on the cell, or focuses it with the keyboard and presses “Enter” or “F2”. The new value is sent through the onChange property and edit mode is exited when the user:
  • Blurs an editable text or multiselect cell,
  • Presses “F2” after making changes to an editable text or multiselect cell, or
  • Selects an option on the editable select cell.
If the user presses the “esc” key, any changes are discarded and the cell exits edit mode.
createColumn("customer_name", {
  headerLabel: "Name",
  editMode: "text",
  onChange: (value, rowIndex) => manageDataState(value, rowIndex);
}),
createColumn("status", {
  headerLabel: "Status",
  editMode: "select",
  onChange: (value, rowIndex) => manageDataState(value, rowIndex);
}),

Working with rows

Defining row data

To show content within the data table body cells, the DataTable.data prop should be passed an array of objects. For type safety, use the TableRow type from Anvil2, and pass the same type interface that was used for the createColumnHelper function as the generic parameter:
import { type TableRow } from "@servicetitan/anvil2/beta";

const rowData: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "Jose Ramirez";
    address: "2401 Ontario Street, Cleveland, OH";
    status: "active";
    amount_due: 1276.43;
    paid_percent: 0.61;
  }
]
<DataTable data={rowData} columns={columns} />
Row data for data tables with grouped columns do not need to include the group id in the data structure. The parameters of the objects in the array passed to the data prop should map to the lowest-level column id, rather than the top-level groups.

Loading data async

The DataTable.data prop can also accept a Promise that resolves TableRow<T>[]. While the Promise is pending, a loading spinner will be rendered in the data table.
async function fetchData() {
  // fetch data from the server
  // this should return TableRow<TableColumns>[]
}
return <DataTable data={fetchData()} columns={columns} />;

Expanding rows

There are two methods for expanding rows to show additional content:
  1. Sub-rows: the expanded content includes additional rows of data the have the same columns of the parent row.
  2. Sub-components: the expanded content includes arbitrary content that is considered “off-grid”, or not part of the table data itself.
Both of these are defined in the DataTable.data array, using the subRows or subComponent properties.

Sub-rows

The subRows property will have the same type requirements as the top-level rows to ensure the data maps to the same column structure.
import { type TableRow } from "@servicetitan/anvil2/beta";

const rowDataWithSubRows: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "Cleveland Guardians";
    address: "2401 Ontario Street, Cleveland, OH";
    status: "active";
    amount_due: 1276.43;
    paid_percent: 0.217;
    subRows: [
      {
        id: "row-1-1",
        customer_name: "Jose Ramirez";
        address: "2401 Ontario Street, Cleveland, OH";
        status: "active";
        amount_due: 276.43;
        paid_percent: 0;
      },
      {
        id: "row-1-1",
        customer_name: "Kyle Manzardo";
        address: "2401 Ontario Street, Cleveland, OH";
        status: "active";
        amount_due: 1000;
        paid_percent: 1;
      },
    ]
  }
]

Sub-components

The subComponent property accepts a ReactNode, which is rendered in the expanded area. Some padding is added around the component, and the direct parent is a flex container.
import { type TableRow } from "@servicetitan/anvil2/beta";

const rowDataWithSubRows: TableRow<TableColumns>[] = [
  {
    id: "row-1",
    customer_name: "Cleveland Guardians";
    address: "2401 Ontario Street, Cleveland, OH";
    status: "active";
    amount_due: 1276.43;
    paid_percent: 0.217;
    subComponent: (
      <Tab flexGrow="1" defaultIndex={0}>
        <Tab.List>
          <Tab.Button id="tab-paid" controls="panel-paid">
            Paid Invoices
          </Tab.Button>
          <Tab.Button id="tab-unpaid" controls="panel-unpaid">
            Unpaid Invoices
          </Tab.Button>
        </Tab.List>
        <Tab.Panel id="panel-paid">...list of paid invoices...</Tab.Panel>
        <Tab.Panel id="panel-unpaid">...list of unpaid invoices...</Tab.Panel>
      </Tab>
    )
  }
]
When a user tabs to a sub-component, they exit the keyboard navigation of the data table, and can interact with the elements of the sub-component until they tab back to the data table cell.

Controlling expanded state

By default, the expanded state of rows is uncontrolled. The DataGrid.defaultExpandedRowIds prop can be used to expand particular rows by default.To control the expanded state of rows, use expandedRowIds with onExpandRow:
const [expandedRowIds, setExpandedRowIds] = useState<string[]>(["row-1"]);
return (
  <DataTable
    data={rowData}
    columns={columns}
    expandedRowIds={expandedRowIds}
    onExpandRow={setExpandedRowIds}
  />
);
The expandedRowIds should correspond to the parent row that expands, not the sub-rows.

Selecting rows

Row selection can be enabled using the DataTable.isSelectable prop. When true, a new column is added as the first column with a checkbox to select the row.

Uncontrolled selection

The defaultSelectedRowIds can be used to select specific rows on load, but keep the selection state uncontrolled.
<DataTable
  data={rowData}
  columns={columns}
  isSelectable
  defaultSelectedRowIds={["row-1"]}
  onSelectRow={(selectedIds: string[]) => {
    // do something with the selected rows
  }}
/>

Controlled selection

The selectedRowIds and onSelectRow can be used to control selection state.
const [selectedRowIds, setSelectedRowIds] = useState<string[]>(["row-1"]);
return (
  <DataTable
    data={rowData}
    columns={columns}
    isSelectable
    selectedRowIds={selectedRowIds}
    onSelectRow={setSelectedRowIds}
  />
);

Pagination

For larger data sets, pagination is the preferred method for only rendering a maximum number of rows at a time. This is done with the DataTable.pagination prop, which accepts either a boolean, or a config object with the following shape:
export type DataTablePaginationConfig<T> = {
  /**
   * Number of rows displayed per page
   * @default 25
   */
  rowsPerPage?: number;
  /**
   * Current page index (0-based)
   */
  currentPageIndex?: number;
  /**
   * Function to get the data for the current page. When used, the data displayed will be
   * fully controlled by the implementor. This is useful for server-side pagination scenarios where
   * the total number of rows may differ from the data array length.
   */
  loadPageData?: ({
    pageIndex,
    pageSize,
  }: {
    pageIndex: number;
    pageSize: number;
  }) => TableRow<T>[] | Promise<TableRow<T>[]>;
  /**
   * Default page index for uncontrolled mode (0-based)
   * @default 0
   */
  defaultPageIndex?: number;
  /**
   * Callback when page changes. Receives the new page index (0-based)
   */
  onPageChange?: (pageIndex: number) => void;
  /**
   * Whether to display the item count information in pagination
   * @default true
   */
  showCount?: boolean;
  /**
   * Total number of rows across all pages. Falls back to data.length if not provided.
   * Useful for server-side pagination scenarios where totalRowCount may differ from data array length.
   */
  totalRowCount?: number;
};

Uncontrolled pagination

To add uncontrolled (client-side) pagination to a Data Table, set pagination to true:
<DataTable data={rowData} columns={columns} pagination />
Pass a config object for more fine-tuned control over the pagination:
<DataTable
  data={rowData}
  columns={columns}
  pagination={{
    rowsPerPage: 5,
    defaultPageIndex: 1,
    onPageChange: (pageIndex) =>
      console.log(`do something with the ${pageIndex}`),
    showCount: false,
  }}
/>

Controlled pagination

To manually control the pagination state, use the onPageChange and currentPageIndex properties:
const [currentPageIndex, setCurrentPageIndex] = useState<number>(0);
return (
  <DataTable
    data={rowData}
    columns={columns}
    pagination={{
      currentPageIndex: 0,
      onPageChange: setCurrentPageIndex,
    }}
  />
);

Server-side pagination

To prevent loading entire large data sets from a server, use the loadPageData property to control the data displayed in the table based on the current page. This can accept an array of row data, or a Promise that resolves an array of row data. While the Promise is pending, a loading spinner will be rendered in the data table.The totalRowCount property should also be used to display the total number of rows, since this is normally determined by the length of the data array.
const [totalRowCount, setTotalRowCount] = useState<number>(0); // update this with the length of the data from the server

async function fetchPageData(pageIndex: number, rowsPerPage: number) {
  // fetch data from the server to show for the given pageIndex and rowsPerPage
  // this can also be used to pre-fetch nearby pages, or save/update local state
  // for fetched data to avoid making extra API calls.
  // this should return TableRow<TableColumns>[], similar to the data prop
}
return (
  <DataTable
    columns={columns}
    pagination={{
      loadPageData: ({ currentPageIndex, rowsPerPage }) =>
        fetchPageData(pageIndex, rowsPerPage),
      totalRowCount,
    }}
  />
);

Footers

Data tables can include footer rows, which can be defined in two ways:
  1. With the footerContent option when defining a column. This method places the footer cell in the same column as the header and body cells according to the column definition.
  2. With the customFooter prop on the DataTable component. This method creates footer rows with cells that have custom-defined col-spans, rather than aligning with the rest of the table content.
If the data table has a specific height or max-height, the footer columns will have sticky positioning when scrolling vertically.

Default footers (footerContent)

The footerContent property can accept a ReactNode or ReactNode[]. If an array is given, each node will add a new footer row.
createColumn("amount_due", {
  headerLabel: "Amount Due",
  renderCell: currencyFormatter,
  // create two footer rows, one with the total amount due, and one with the average
  footerContent: [
    <span key="total">
      {currencyFormatter(rowData.reduce((acc, row) => acc + row.amount_due, 0))}
    </span>,
    <span key="average">
      {currencyFormatter(
        rowData.reduce((acc, row) => acc + row.amount_due, 0) / data.length,
      )}{" "}
      (avg)
    </span>,
  ]
}),

Custom footers (DataTable.customFooter)

The DataTable.customFooter prop accepts an array of arrays, which can include objects that have content: ReactNode and colSpan: number properties. The content will render within the cell, and the colSpan will determine how many columns that cell covers.
<DataTable
  data={rowData}
  columns={columns}
  customFooter={[
    // first footer row
    [
      { content: "Cell that covers 2 columns", colSpan: 2 },
      { content: "Cell that covers 4 columns", colSpan: 4 },
    ],
    // second footer row
    [
      { content: <em>Cell that covers 1 column</em>, colSpan: 1 },
      { content: "", colSpan: 1 },
      { content: "Cell that covers 4 columns", colSpan: 4 },
    ],
  ]}
/>

Background color

The DataTable.background prop can be set to "strong" when the data table is rendered against darker backgrounds.
<DataTable data={rowData} columns={columns} background="strong" />

React Accessibility

  • The DataTable implements proper ARIA attributes for table structure and interactive elements
  • Sortable column headers are keyboard accessible and announce sort state to screen readers
  • Row selection checkboxes include appropriate aria-label attributes
  • Expand/collapse buttons include aria-label, aria-expanded, and aria-controls attributes
  • The component uses proper semantic HTML table elements (table, thead, tbody, tr, th, td)
  • Loading states are announced to screen readers using aria-live regions
  • Focus management ensures keyboard navigation follows expected patterns
  • Editable cells are keyboard accessible and announce their editable state

Feedback

The DataTable is in beta as the team gathers feedback on the API and visual design. Once stabilized, it will be promoted to the stable layer. If you encounter issues or have suggestions, reach out to the Anvil team in #ask-designsystem on Slack.
Last modified on January 23, 2026