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
DataTableyet - Row grouping is not yet supported
- Limited default formatting and editable cell options are available
- Implementation
- DataTable Props
- Column Definition (ColumnDef)
- Pagination Configuration
Common Examples
DataTable component provides a comprehensive solution for displaying complex tabular data with built-in support for sorting, pagination, row selection, and expansion.Basic Usage
TheDataTable component requires two props: data (row data) and columns (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. Theid property should always be included, as it is used internally for sorting, selection, etc.Defining columns
To create column definitions, use thecreateColumnHelper factory function. This returns a function, which can then be used to create type-safe column definitions.createColumn function created above accepts two parameters:id:string|{ group: string }. Thestringvalue must match one of the properties of the type interface provided to the factory function (id,customer_name, etc.). The object with thegroupparameter can accept any string, and is used to group column headers.column: configuration for column (see below for examples)
DataTable.columns prop takes an array of column definitions, each created using the createColumn function created above.header.label supports the same limited inline Markdown as FieldLabel.Basic columns
Grouped columns
Pinned columns
Columns can be pinned to the left or right side of the table using thepinned property in the column options. Pinned columns have sticky position when the table is scrolled horizontally.Sorting columns
If thesortable 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.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 thesortable property in the column definition:Controlling sort (props)
Default sorted column
A column can be sorted by default using theDataTable.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.Controlled sort state
It is also possible to fully control the sorting state using thesortedColumn and onSort props of the DataTable.Working with cells
Formatting data in cells
TherenderCell 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. When your read view returns JSX or multiline content but default sorting should still use plain text, add getCellText for the simple case, or getReadRenderResult when you want one hook to return both rendered content and raw sortable 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:booleanFormatter: show boolean as customizable text (e.g., “True”/“False”, “Yes”/“No”, “On”/“Off”)chipsFormatter: show chips in cell, with optional truncationcurrencyFormatter: show number as currency, with i18n optionsdateFormatter: show date with i18n options (accepts ISO string)dateTimeFormatter: show date and time with i18n options (accepts ISO string or Date object)numberFormatter: show number with i18n options, grouping separators, and notation stylespercentFormatter: show number as percentage, with i18n optionstimeFormatter: show time with i18n options (accepts ISO time string)yearlessDateFormatter: show month and day without year (accepts Temporal PlainMonthDay string or YearlessDate object)
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.currencyFormatter and percentFormatter, pass an object as the second parameter of the functions:Boolean formatter
ThebooleanFormatter displays true as “True” and false as “False” by default. Customize the labels for i18n or different use cases:Number formatter
ThenumberFormatter provides flexible number formatting with full Intl.NumberFormat support:Date formatter
ThedateFormatter displays plain dates with locale-aware formatting. It accepts ISO date strings in "YYYY-MM-DD" format (like Temporal.PlainDate).For JavaScript Date objects or date-times with timezone information, use dateTimeFormatter instead.Time formatter
ThetimeFormatter displays times with locale-aware formatting. It accepts ISO time strings ("HH:mm:ss" or "HH:mm"):Date-time formatter
ThedateTimeFormatter displays both date and time together. It accepts ISO date-time strings ("YYYY-MM-DDTHH:mm:ss") or JavaScript Date objects.Timezone handling:- Strings without timezone info are treated as “plain date-times” - no timezone conversion or display occurs
- Strings with timezone info honor the provided timezone and display the timezone abbreviation (e.g., “PST”, “UTC”)
Dateobjects always display their timezone: local timezone by default, or use thetimeZoneoption to specify an IANA timezone (e.g.,"America/New_York","Europe/London","UTC")
Yearless date formatter
TheyearlessDateFormatter displays month and day without the year. It accepts:- Temporal PlainMonthDay string format (
"--MM-DD") YearlessDateobject ({ month: number, day: number }) from Anvil2’sDateFieldYearlesscomponent
DateFieldYearless component values (which return YearlessDate objects):| Formatter | Options |
|---|---|
booleanFormatter | trueLabel: string, falseLabel: string |
chipsFormatter | truncateChips: boolean |
currencyFormatter | locale: string, currency: string |
dateFormatter | locale: string, format: "short" | "medium" | "long" | "full" | Intl.DateTimeFormatOptions |
dateTimeFormatter | locale: string, dateFormat: "short" | "medium" | "long" | "full" | Intl.DateTimeFormatOptions, timeFormat: "short" | "medium" | "none" | Intl.DateTimeFormatOptions, timeZone: IanaZone |
numberFormatter | locale: string, minimumFractionDigits: number, maximumFractionDigits: number, useGrouping: boolean, notation: "standard" | "scientific" | "engineering" | "compact", signDisplay: "auto" | "never" | "always" | "exceptZero" |
percentFormatter | locale: string, decimals: number |
timeFormatter | locale: string, format: "short" | "medium" | Intl.DateTimeFormatOptions |
yearlessDateFormatter | locale: string, format: "short" | "long" | Intl.DateTimeFormatOptions |
Chips formatter
ThechipsFormatter 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
truncateChipsoption totrueto automatically truncate chips when they overflow the cell boundaries. - Map the
valuefrom therenderCellto an object that includesChipProps(for adjusting the color, icon, etc.).
Custom formats
To customize the format of a cell in a way that isn’t supported by the default formatters, use therenderCell property, and return a ReactNode.Overflow surface
Read-only leaf columns can opt into an overflow disclosure surface. Useoverflow: { mode: "surface" } when the mounted cell content may be clipped by a constrained column width or height and users need access to the full read-only value.Enter, Space, or F2. Short cells that are not clipped do not show the affordance.overflow is only available for read-only leaf columns. It is not available on grouped columns or columns with editConfig.Editable cells
TheeditConfig property can be used when defining columns to make data table cells editable. The primary modes are "text", "number", "boolean", "select", "multiselect", and "custom".Custom edit mode is for object-backed cells that keep a formatted read view while opening a custom multi-field editor surface.renderCellstays on the read path.getCellTextis the preferred way to supply sortable plain text when the read UI is rich or multiline.getReadRenderResultremains available for advanced cases where one hook should return both rendered read content and its sortable raw string.renderEditorreceives a typedcontrollerwith draft state, validation, focus, and close helpers.onCommitpersists the final committed object value, whileonDraftUpdateremains available for per-keystroke draft updates.- Surface title, width, height, and close-button copy live under
editConfig.surface. - If you want Enter-to-submit behavior in a custom editor, wrap the editable contents in a
<form onSubmit={controller.handleSubmit}>; Enter only submits through that form contract. - Validation remains informational by default; set
blockOnValidationError: trueif submit-style close requests should stay open while validation errors exist.
- The text cell changes to a text input.
- The number cell changes to a formatted number input with keyboard increment/decrement support.
- The boolean cell opens a constrained true/false selector.
- The select cell triggers a menu dropdown.
- The multiselect cell triggers a popover with a search field and list view.
- The custom editable cell opens an anchored custom editor surface from
renderEditor.
Escape discards it and returns focus to the originating cell. Enter submits a custom editor only when the focused control lives inside a form wired with onSubmit={controller.handleSubmit}. Validation errors stay informational unless blockOnValidationError is set to true.In
renderEditor, wrap custom editor contents in a <form onSubmit={controller.handleSubmit}> if you want Enter to submit the draft.Select All in async multiselect cells
Async multiselect edit cells forwardeditConfig.selectAll to the underlying MultiSelectMenu. Because editConfig is defined once per column and shared by every cell, a static checkState cannot reflect an individual cell’s selection. Pass a function for checkState to derive the Select All state from each cell’s current selection:Custom edit mode
Use custom mode when a cell’s committed value is an object and the read view should stay separate from the edit UI. This example keeps an address object intact, formats the read state as multiline text, usesvalidateDraft plus onRequestClose to keep invalid submit-style closes open, and commits the full object through editConfig.onChange. Because the editor is wrapped in <form onSubmit={controller.handleSubmit}>, pressing Enter inside a single-line field submits through that form.Empty cell content
By default, cells with empty values (null, undefined, or empty string) display an em dash (—). Customize this at the table or column level using the emptyCellContent prop.Table-wide default
SetemptyCellContent on the DataTable to change the empty cell display for all columns:Per-column override
SetemptyCellContent on individual column definitions to override the table default for specific columns:Working with rows
Defining row data
To show content within the data table body cells, theDataTable.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: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.Empty states
Use theemptyState prop to display a customizable empty state when the table has no data. The prop accepts an object with an optional svg illustration component and optional content (any ReactNode).Indicating errors on rows and cells
DataTable supports displaying error states on individual cells or entire rows. Errors are indicated with a red border, red text, and an error icon. Use themeta property on row data to set errors.When setting errors, prefer using descriptive string messages instead of boolean values. String messages provide accessible context that screen readers announce, helping users understand why a cell or row is in an error state.
Cell-level errors
Set cell-level errors using themeta.errors property. This is an object keyed by column id, where the value is either a string message or true:Row-level errors
Set row-level errors using themeta.rowError property. This displays an error icon in a dedicated column at the start of the row:rowError set, and is pinned to the left side of the table along with other internal columns (selection, expansion).Combining cell and row errors
Cell-level and row-level errors can be used together. Use row-level errors for general validation issues that affect the entire row, and cell-level errors for specific field validation:Indicating warnings on rows and cells
DataTable supports displaying warning states on individual cells or entire rows. Warnings are indicated with a yellow border and a warning icon, and are used for non-blocking advisory issues. Use themeta property on row data to set warnings.When setting warnings, prefer using descriptive string messages instead of boolean values. String messages provide accessible context that screen readers announce, helping users understand why a cell or row is in a warning state.
Cell-level warnings
Set cell-level warnings using themeta.warnings property. This is an object keyed by column id, where the value is either a string message or true:Row-level warnings
Set row-level warnings using themeta.rowWarning property. This displays a warning icon in a dedicated column at the start of the row:rowWarning set, and is pinned to the left side of the table along with other internal columns (selection, expansion).Combining cell and row warnings
Cell-level and row-level warnings can be used together. Use row-level warnings for general advisory issues that affect the entire row, and cell-level warnings for specific field-level advisories:Error and warning priority
When both errors and warnings exist on the same cell or row, errors take priority. The error styling and icon are displayed, and the warning is hidden. This ensures that blocking validation issues are always surfaced over advisory messages: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.
DataTable.data array, using the subRows or subComponent properties.Sub-rows
ThesubRows property will have the same type requirements as the top-level rows to ensure the data maps to the same column structure.Sub-components
ThesubComponent 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.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. TheDataGrid.defaultExpandedRowIds prop can be used to expand particular rows by default.To control the expanded state of rows, use expandedRowIds with onExpandRow:The
expandedRowIds should correspond to the parent row that expands, not the sub-rows.Selecting rows
Row selection can be enabled using theDataTable.isSelectable prop. When true, a new column is added as the first column with a checkbox to select the row.Uncontrolled selection
ThedefaultSelectedRowIds can be used to select specific rows on load, but keep the selection state uncontrolled.Controlled selection
TheselectedRowIds and onSelectRow can be used to control selection state.Read-only rows
Pass row IDs toreadOnlyRowIds to render those rows as read-only. Read-only rows gray their plain-text cell content, show a not-allowed cursor on the row surface, and render the selection checkbox as disabled and read-only. Links, chips, and custom cell content keep their own color.Pre-seeded selectedRowIds is still honored — readOnlyRowIds only gates user interaction (checkbox toggling), not the visual selection state.Clicking rows
PassonClickRow to be notified when a user clicks a row or activates it from the keyboard. The callback receives the clicked row’s id, in the same form as onSelectRow. Clickable rows show a pointer cursor.Row click stays independent of selection: clicking the selection checkbox toggles selection without firing onClickRow, and clicking elsewhere on the row fires onClickRow without toggling selection. Clicks on interactive cell content (links, buttons, menus) and on editable cells do not fire onClickRow, and read-only rows (readOnlyRowIds) never fire it.Keyboard users activate a row by focusing a non-interactive cell and pressing “Enter”.button, a) or carry an interactive role such as role="button". The DataTable recognizes those and excludes them from row click, and they are also required for keyboard and screen-reader accessibility. A click handler on a bare div or span with no role still bubbles to the row and fires onClickRow — call event.stopPropagation() in that handler to opt the element out.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 theDataTable.pagination prop, which accepts either a boolean, or a config object with the following shape:Uncontrolled pagination
To add uncontrolled (client-side) pagination to a Data Table, setpagination to true:Controlled pagination
To manually control the pagination state, use theonPageChange and currentPageIndex properties:Rows per page selection
The pagination controls include a dropdown selector that allows users to change the number of rows displayed per page. This is enabled by default with options for 25, 50, and 100 rows per page.To show the dropdown selector and the available options, use therowsPerPageOptions and onRowsPerPageChange props to control the rowsPerPage state:When the user changes the rows per page selection, the page automatically resets to the first page if the current page index no longer exists.
Server-side pagination
To prevent loading entire large data sets from a server, use theloadPageData 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.The loadPageData function receives a sorting parameter when a column is sorted, allowing the server to return data in the correct sort order. When sorting changes, the page index automatically resets to 0.Cursor pagination / unknown total
Use cursor pagination when the server cannot provide a stabletotalRowCount. Set pagination.mode to "cursor", then control previous and next availability with hasPrevPage and hasNextPage. In cursor mode, DataTable renders previous and next controls only; page numbers and total count text are hidden because the total size is unknown.currentPageIndex remains a navigation index for controlled state and direction detection. Cursor tokens stay owned by the implementor.Cursor mode disables caching by default. Provide
cache only when page identity is stable.Caching
DataTable caches loadPageData results by default. When a user navigates to a previously loaded page, the cached data displays instantly without calling loadPageData again or showing a loading spinner.Configure caching behavior through the cache property on the pagination config:cache only when page identity is stable.Clearing the cache
Use a ref to imperatively clear the page data cache without refetching:refresh() to clear the cache, refetch data, and reset to the first page. Use this when the underlying data has changed and the table needs to reflect the update:Automatic cache invalidation
The cache automatically clears when any of the following change:refreshKey— Use this to invalidate the cache when external filters or search parameters change. The page index resets to 0rowsPerPage— Cached data becomes invalid when the page size changesrefresh()— Callingrefresh()on the ref clears the cache and refetches from page 0
Caching only applies when using
loadPageData for server-side pagination. Client-side pagination with the data prop does not use this cache.Footers
Data tables can include footer rows, which can be defined in two ways:- With the
footerContentoption 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
customFooterprop on theDataTablecomponent. 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.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.Optimizing re-renders with getRowVersion
By default, every row in a DataTable re-renders whenever the parent component re-renders. For most tables this is fine, but on large tables or tables whose parent re-renders often, you can opt each row into memoization by providing a getRowVersion function.getRowVersion receives a row and returns a value that signals “this row has changed.” Rows only re-render when that value differs between renders (or when table-managed state like selection or expansion changes). If a row’s cell rendering depends on state outside the row object (for example, a highlighted row ID stored elsewhere), fold that state into the returned value so memoization stays correct.getRowVersion is not provided, rows behave exactly as before — no memoization, no surprises.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-labelattributes - Expand/collapse buttons include
aria-label,aria-expanded, andaria-controlsattributes - The component uses proper semantic HTML table elements (
table,thead,tbody,tr,th,td) - Loading states are announced to screen readers using
aria-liveregions - Focus management ensures keyboard navigation follows expected patterns
- Editable cells are keyboard accessible and announce their editable state