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:
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.
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
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.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:| Formatter | Options |
|---|
currencyFormatter | locale: string, currency: string |
percentFormatter | locale: string, decimals: number |
chipsFormatter | truncateChips: boolean |
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:
- Set the
truncateChips option to true to automatically truncate chips when they overflow the cell boundaries.
- 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,
}
),
}),
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:
- Sub-rows: the expanded content includes additional rows of data the have the same columns of the parent row.
- 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}
/>
);
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;
};
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,
}}
/>
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,
}}
/>
);
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,
}}
/>
);
Data tables can include footer rows, which can be defined in two ways:
- 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.
- 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.
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>,
]
}),
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.<DataTable
data={data}
columns={columns}
background="default"
isSelectable
pagination={{
rowsPerPage: 25,
defaultPageIndex: 0,
showCount: true,
}}
onSort={(sortedColumn) => console.log(sortedColumn)}
onSelectRow={(selectedIds) => console.log(selectedIds)}
/>
DataTable Props
Column definitions created using the createColumnHelper factory function. Each column definition specifies how data should be displayed and what features are enabled.
data
TableRow<T>[] | Promise<TableRow<T>[]>
required
The data for the table. Must be an array of objects where each object has an id property. Can be a Promise for async data loading, in which case a loading spinner displays until the data resolves.
background
"default" | "strong"
default:"default"
The background color of the table container. Use "strong" for better contrast when displaying the table on colored backgrounds.
Custom footer content for the table. Each array represents a footer row, with objects specifying content and colSpan. This footer is overridden by the footerContent property of individual columns.
The default expanded row IDs for uncontrolled expansion. Use "all" to expand all rows, or provide an array of specific row IDs to expand.
The default selected row IDs for uncontrolled selection.
The default sorted column for uncontrolled sorting. Object with id (column identifier) and desc (boolean for descending order).
The controlled expanded row IDs. Use "all" to expand all rows, or provide an array of specific row IDs. Must be used with onExpandRow for controlled expansion.
isSelectable
boolean | ((row: T) => boolean)
default:"false"
Whether rows are selectable with checkboxes. Can be a boolean to enable selection for all rows, or a function to conditionally enable selection based on row data.
onExpandRow
(expandedRowIds: 'all' | TableRow<T>['id'][]) => void
Callback when row expansion state changes. Receives either "all" when all rows are expanded, or an array of expanded row IDs.
onSelectRow
(selectedRowIds: TableRow<T>['id'][]) => void
Callback when row selection state changes. Receives an array of selected row IDs.
onSort
(sortedColumn: SortedColumn | undefined) => void
Callback when column sorting state changes. Receives the sorted column object with id and desc properties, or undefined when sorting is cleared.
Pagination configuration. When true, enables pagination with default settings (25 items per page). When false or undefined, no pagination is applied and all data is shown. Provide a DataTablePaginationConfig object for custom configuration.
The controlled selected row IDs. Must be used with onSelectRow for controlled selection.
The controlled sorted column. Object with id (column identifier) and desc (boolean for descending order). Must be used with onSort for controlled sorting.
const columnHelper = createColumnHelper<DataType>();
const column = columnHelper("fieldName", {
headerLabel: "Column Header",
sortable: true,
resizable: true,
align: "start",
minWidth: 150,
maxWidth: 400,
renderCell: (value, rowDepth) => <CustomComponent value={value} />,
});
Column Definition Parameters
Columns are created using the createColumnHelper factory function, which provides type-safe column definitions.The label text displayed in the column header.
Column Definition Options (below)
Column Definition Options
align
"start" | "center" | "end"
The horizontal alignment of the header and cell content within the column.
Nested columns for creating grouped column headers. Used when creating column groups with the { group: string } syntax in createColumnHelper.
editMode
"text" | "select" | "multiselect"
The edit mode for cells in this column. When set, cells become editable inline. Must be used with onChange callback.
The content displayed in the footer cell for this column. When an array is provided, multiple footer rows are created, one for each array element.
The maximum width of the column in pixels. Useful for constraining column expansion.
The minimum width of the column in pixels. Useful for ensuring minimum readability.
onChange
(value: T[keyof T], rowId: string) => void
Callback function called when the value of an editable cell is saved. Receives the new value and the row ID. Must be used with editMode.
options
{ value: T[keyof T]; label: string }[]
The options for select or multiselect edit modes. Each option has a value (the data value) and label (display text). Required when editMode is "select" or "multiselect".
The pinning location for the column. Pinned columns remain visible during horizontal scrolling.
renderCell
(value: T[keyof T], rowDepth: number) => ReactNode
Custom rendering function for cell content. Receives the cell value and the row depth (0 for top-level rows, 1+ for nested rows). Return a React node to customize the cell display.
Whether the column can be resized by dragging the column border. When enabled, users can adjust column width within the minWidth and maxWidth constraints.
sortable
boolean | ((valueA: T[keyof T], valueB: T[keyof T]) => number)
Whether the column is sortable. When true, uses default comparison. When a function is provided, uses custom sorting logic (similar to Array.sort comparator).