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: Row grouping is not yet supported. Limited default formatting and editable cell options are available.

Common Examples

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).
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.

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.
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.
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 the pinned property in the column options. Pinned columns have sticky position when the table is scrolled horizontally.
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.

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:

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.
Controlled sort state
It is also possible to fully control the sorting state using the sortedColumn and onSort props of the DataTable.

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. 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 truncation
  • currencyFormatter: show number as currency, with i18n options
  • dateFormatter: 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 styles
  • percentFormatter: show number as percentage, with i18n options
  • timeFormatter: 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.
Import these functions from Anvil2 and apply them when defining the columns:To change the internationalization options for the currencyFormatter and percentFormatter, pass an object as the second parameter of the functions:

Boolean formatter

The booleanFormatter displays true as “True” and false as “False” by default. Customize the labels for i18n or different use cases:

Number formatter

The numberFormatter provides flexible number formatting with full Intl.NumberFormat support:

Date formatter

The dateFormatter 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

The timeFormatter displays times with locale-aware formatting. It accepts ISO time strings ("HH:mm:ss" or "HH:mm"):

Date-time formatter

The dateTimeFormatter 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”)
  • Date objects always display their timezone: local timezone by default, or use the timeZone option to specify an IANA timezone (e.g., "America/New_York", "Europe/London", "UTC")

Yearless date formatter

The yearlessDateFormatter displays month and day without the year. It accepts:
  • Temporal PlainMonthDay string format ("--MM-DD")
  • YearlessDate object ({ month: number, day: number }) from Anvil2’s DateFieldYearless component
When using with DateFieldYearless component values (which return YearlessDate objects):
Other options are available for specific formatters:

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.).

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.
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.

Overflow surface

Read-only leaf columns can opt into an overflow disclosure surface. Use overflow: { 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.
When the cell content is clipped, DataTable shows a trailing affordance on hover or focus. Users can open the Surface by clicking the cell, or by focusing the cell and pressing 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

The editConfig 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.
  • renderCell stays on the read path.
  • getCellText is the preferred way to supply sortable plain text when the read UI is rich or multiline.
  • getReadRenderResult remains available for advanced cases where one hook should return both rendered read content and its sortable raw string.
  • renderEditor receives a typed controller with draft state, validation, focus, and close helpers.
  • onCommit persists the final committed object value, while onDraftUpdate remains 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: true if submit-style close requests should stay open while validation errors exist.
For the full custom-mode guide, see Custom Edit Mode.The appearance of the cell content still respects the read configuration. Each mode behaves differently when editing:
  • 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.
Editable cells open when the user clicks a cell, or focuses it and presses “Enter”, “F2”, or “Space”. Simple modes commit through their normal close behavior, such as blur, selection, or confirming the inline field. In custom mode, submit-style close requests commit the current draft, while 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 forward editConfig.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, uses validateDraft 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.
For draft callbacks, close interception, and focus details, see Custom Edit Mode.

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

Set emptyCellContent on the DataTable to change the empty cell display for all columns:

Per-column override

Set emptyCellContent on individual column definitions to override the table default for specific columns:
Column-level values take precedence over the table-level default. When neither is set, the default em dash (—) is displayed.

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:
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 the emptyState 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 the meta 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 the meta.errors property. This is an object keyed by column id, where the value is either a string message or true:
The error icon displays in the cell. When a string message is provided, hovering or focusing the icon shows a tooltip with the error message, and screen readers announce the error.

Row-level errors

Set row-level errors using the meta.rowError property. This displays an error icon in a dedicated column at the start of the row:
The row error column automatically appears when any row has a 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 the meta 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 the meta.warnings property. This is an object keyed by column id, where the value is either a string message or true:
The warning icon displays in the cell. When a string message is provided, hovering or focusing the icon shows a tooltip with the warning message, and screen readers announce the warning.

Row-level warnings

Set row-level warnings using the meta.rowWarning property. This displays a warning icon in a dedicated column at the start of the row:
The row warning column automatically appears when any row has a 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:
  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.

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.
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:
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.

Controlled selection

The selectedRowIds and onSelectRow can be used to control selection state.

Read-only rows

Pass row IDs to readOnlyRowIds 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

Pass onClickRow 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”.
Custom cell content with its own click behavior must use a real interactive element (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.

Bulk actions

Pass bulkActions to a selectable table to show a bar above the table while rows are selected. The bar shows the selection count, a “Cancel” button that clears the selection, and up to two actions: an optional secondary action and a primary action. Use them for the actions users take most often.
Each action receives the current selection:
  • { mode: "rows", rowIds } when users checked rows. Like onSelectRow, the ids are strings, so convert numeric ids before sending them to your backend.
  • { mode: "all", selectedRowCount } when users chose “Select all N items”. selectedRowCount is the count shown in the bar. Rows that haven’t loaded are included, so apply the change on the server. Rows that isSelectable rejects and rows in readOnlyRowIds are excluded, and the server must skip them too.
The selection clears after an action returns, or after its promise resolves. While a promise is pending, the button shows a spinner and the other actions are disabled. If the promise rejects, the rows stay selected, so you can show the error in a toast and users can try again.

Toggle actions

When an action is a toggle, such as hiding and showing an item, provide both states as a secondary and a primary action. The selection can mix both states; the action users choose overrides every selected row.

Singular and plural labels

Pass a function as label to change the wording with the selection. It receives the selected count, which is the “Select all” count after users choose “Select all N items”. This works on every action, including the menu button and its items.
Translated apps can call their own plural helper inside the function.

Many actions

When a table needs more than two bulk actions, pass items instead of onClick to the primary action to open a menu of actions. Each item takes label, onClick, and optionally icon, disabled, and data-* attributes.

Destructive actions

Use appearance: "danger" on the primary action, or "danger-secondary" on a secondary action. Return false to keep the rows selected while a confirmation dialog is open, then call clearSelection() on the ref once users confirm. If users cancel the dialog, the rows stay selected.

Selecting all rows across pages

Set selectAll: true to show a “Select all N items” button in the bar. It needs offset pagination with a known total (totalRowCount, or the length of data), and it’s hidden in cursor mode or when every row fits on one page. The header checkbox still selects only the current page.The count includes sub-rows and leaves out rows users can’t select. When some rows can’t be selected, the button reads “Select all N items of N”. With data, the table counts the rows itself. With loadPageData, it can’t see rows that haven’t loaded, so it uses totalRowCount unless you pass selectableRowCount:

With filters and toolbar actions

While rows are selected, the bulk action bar replaces the filter bar and toolbar. They stay mounted, so in-progress filter input is kept.className, style, layout props, and data-* attributes always apply to the table, not to the area above it. A height set on the table doesn’t include the filter bar or bulk action bar.

Accessibility

  • The selection count is announced through a polite live region, for example “4 items selected”.
  • Selecting a row doesn’t move focus to the bar.
  • “Cancel” moves focus to the select-all checkbox in the table header. So does a completed action, when the bar closing would otherwise drop focus.

Filters and toolbar actions

Use the filters prop to render a Filter Bar above the table. Pass the same filter definitions you would pass to FilterBar as filters.items. Use the tools prop to add buttons, such as Export, to the right of the filter bar.DataTable does not filter rows itself. It reports changes through filters.onChange, and your code applies the filters to data or to your loadPageData request.

Client-side filtering

Apply the filters from filters.onChange to your data and pass the result back as data. Memoize the filters.items array. A new array identity re-syncs the filter bar and discards in-progress input.

Server-side filtering

With pagination.loadPageData, store the filters in state and read them in the loader. When a filter changes, DataTable returns to the first page, clears the page cache, and calls loadPageData again, so the loader always sees the new filters. Update totalRowCount to match the filtered result.

Controlled filtering

By default, inline filters commit each change immediately. Set filters.controlled to hold inline commits for filters that have an Apply step. Filters without an Apply step still commit immediately, and the filter drawer always batches its changes until the user applies them.

Drawer-only filters

Set drawerOnly: true on a filter to keep it out of the toolbar and show it only in the filter drawer.

Toolbar actions

Pass tools.actions to render buttons to the right of the filter bar. Each action takes the props of a toolbar button plus a stable id. Use children for a text label, or icon with an aria-label for an icon-only button. Only Anvil2 toolbar buttons are accepted; arbitrary nodes are not.tools works without filters. Use tools.size and filters.size to change the size of the toolbar controls and the filter bar. Both default to "medium".

Edit columns

Use the columnEditing prop to add an Edit Columns button at the end of the toolbar. The button opens a drawer where users can drag columns to reorder them, hide or show them with the eye icon, and pin them to the left of the table with the lock icon. The toolbar appears even when the table has no filters or tools.You own the layout. DataTable applies columnEditing.layout to columns, and reports changes through columnEditing.onLayoutChange. It doesn’t store the layout, so keep it in state, or load and save it wherever your product persists user preferences. An empty layout shows every column in the order of columns.
Each DataTableColumnPref has the column id, whether it’s visible, and whether it’s pinned. Ids that match no column are ignored, and columns missing from the layout are added as visible, so a saved layout keeps working when columns change.

Applying and resetting

Edits stay in the drawer until the user selects Apply. Only then does onLayoutChange fire with the full layout. If the user closes the drawer with unapplied edits, the drawer asks them to confirm that they want to discard the changes.Reset Defaults asks for confirmation, then calls onLayoutChange with the layout implied by columns: the same order, every column visible, and left-pinned columns pinned as declared.The drawer doesn’t let the user hide the last column whose removal would leave the table empty. When an alwaysVisible or right-pinned column remains, the user can hide every column in the drawer.

Always-visible columns

Set alwaysVisible: true on a column to keep it out of the drawer. Users can’t hide, reorder, or pin it, and it keeps its position in the columns array. Columns with pinned: "right" are left out of the drawer in the same way. Use this for identifying columns or a row actions column.alwaysVisible doesn’t pin a column. A column the user pins still renders ahead of an unpinned alwaysVisible column. To keep a column first, also set pinned: "left".

Sorting and hidden columns

When the user hides the column that the table is sorted by, DataTable clears the sort and calls onSort with undefined. With pagination.loadPageData, the loader stops receiving that column in sorting.

One drawer for several tables

When a page has several tables, a single drawer can edit all of them. Set columnEditing.hideButton on each table so it keeps applying its layout without showing its own button. Then render a DataTableEditColumnsDrawer with one section for each table, and open it from your own button. Each section takes the same columns as its table.Apply reports only the sections the user changed. Reset Defaults resets every section.

Adding rows

Use the rowAdd prop to render an Add row button in a toolbar below the table. The button only calls onAddRow, so the page owns the flow, such as a dialog or a server-side check. DataTable does not know when your flow finishes, so update data when it does. If the table loads rows with pagination.loadPageData, call refresh() on the table’s ref instead. Set loading to show the button as busy while an external check runs. See DataTableRowAdd for every property.

Suggested pattern: a dialog with a form

We suggest opening a dialog that holds a form for the new row. The dialog gives the user room for fields that don’t fit in a table cell, and it is the place to validate and to call your API before the row appears.For the form fields, use the Form.* components from @servicetitan/anvil2-ext-common. They bind to a field state, so validation and values live in a store rather than in the dialog. Validate on save, add the saved row to your data, and close the dialog when the save succeeds.

Empty state

A table that starts empty can repeat the Add button in its empty state. Set emptyState.includeAddRow and the button appears under the empty state content, with the same label and handler as the toolbar button. It has no effect without rowAdd.

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:

Uncontrolled pagination

To add uncontrolled (client-side) pagination to a Data Table, set pagination to true:
Pass a config object for more fine-tuned control over the pagination:

Controlled pagination

To manually control the pagination state, use the onPageChange 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 the rowsPerPageOptions 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 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.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 stable totalRowCount. 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:
Cursor mode disables caching by default. Provide cache only when page identity is stable.

Clearing the cache

Use a ref to imperatively clear the page data cache without refetching:
Call 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 0
  • rowsPerPage — Cached data becomes invalid when the page size changes
  • refresh() — Calling refresh() 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:
  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.

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.
If 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-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 October 19, 2026