Related components

Table

DataTable provides complex features for tables, like sorting and pagination.

It's built around the @tanstack/react-table library and exposes the table state from that library directly. All util functions from the library are also compatible with DataTable. It's worth a good read of @tanstack/react-table's documentation too, since we won't be repeating much of it here.

DataTable and its subcomponents are designed to be very simple to use. This is achieved by abstracting complex and/or boilerplate logic away from the consumer. For example, DataTable.Pagination can be dropped inside a DataTable to paginate the table's data without the need to add any configuration on DataTable itself. This is achieved by having DataTable.Pagination itself use the applyPagination and setPageSize methods exposed by useDataTable on its first render. This pattern should be replicated wherever practical to maintain the best developer experience possible.

Anatomy

The root DataTable component manages the table's state and exposes it via the React Context API. This state can be accessed by any child components by calling useDataTable.

Other DataTable components call useDataTable and provide useful default implementations for common patterns. For example, DataTable.Head will render a header for every column defined in the parent DataTable. DataTable.Body will render a row for every data item. DataTable.Table combines both DataTable.Head and DataTable.Body.

Using defaults vs using rolling your own

Here's a simple config for some table data and columns.

// import { createColumnHelper } from '@tanstack/react-table'

const columnHelper = createColumnHelper<{
  name: string
  hobby: string
}>()

const columns = [
  columnHelper.accessor('name', {
    cell: (info) => info.getValue()
  }),
  columnHelper.accessor('hobby', {
    cell: (info) => info.getValue()
  }),
  // Columns created with columnHelper.display won't be sortable.
  // They need a header to be set manually since they're not just reading
  // a property from the row.
  columnHelper.display({
    cell: (info) => <button>do something</button>,
    header: 'Actions'
  })
]

const data = [
  { name: 'chrissy', hobby: 'bare-knuckle boxing' },
  { name: 'agatha', hobby: 'crossfit' },
  { name: 'betty', hobby: 'acting' }
]

There are basically two ways to use DataTable to build a table from this config. The first uses the highest-level components that are bundled into DataTable to provide useful default behaviours with minimal code. The second directly accesses the state from DataTable and combines it with the Table UI components to achieve the same thing. This demonstrates how you could create more custom table UIs without the need to extend the high-level components.

With Defaults

The following two examples are exactly equivalent in their output. The second example is included to demonstrate what DataTable's subcomponents do: they bundle up UI components and logic to provide useful defaults. They exist at various levels of abstraction, e.g. DataTable.Table renders DataTable.Body, which renders DataTable.Row. This means that you can use whichever component provides useful functionality for your use case while still being low-level enough to let you combine it with your own custom logic.

<DataTable columns={columns} data={data}>
  <DataTable.Table sortable className="mb-4"/>
  <DataTable.Pagination pageSize={5} />
</DataTable>

<DataTable columns={columns} data={data}>
  <Table>
    <DataTable.Head sortable />
    <DataTable.Body />
  </Table>
  <DataTable.Pagination pageSize={5} />
</DataTable>

Rolling your own

If you need more flexibility than the default implementations provide, you can roll your own. Note that you can mix and match default implementations with your own. For example you could write your own table head implementation but use DataTable.Body for the body.

Note also that useDataTable can only be called by a child component of DataTable. In a real example, you'll probably have a separate named component which makes the useDataTable call, because if you're not using the defaults as above then you probably have some complex logic involved. In this example we've got an inline child component for simplicity.

<DataTable columns={columns} data={data}>
  {() => {
    const { getHeaderGroups, getRowModel, setGlobalFilter, getState } =
      useDataTable()
    const { globalFilter } = getState()

    return (
      <>
        <Label htmlFor="search">User search</Label>
        <SearchInput
          name="search"
          value={globalFilter}
          onChange={setGlobalFilter}
        />
        <Table>
          <Table.Header>
            {getHeaderGroups().map((headerGroup) => (
              <Table.Row key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  const sort = header.column.getIsSorted()
                  return (
                    <Table.HeaderCell
                      onClick={header.column.getToggleSortingHandler()}
                      {...props}
                    >
                      {flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                      {sort && { asc: '^', desc: 'v' }[sort as string]}
                    </Table.HeaderCell>
                  )
                })}
              </Table.Row>
            ))}
          </Table.Header>
          <Table.Body>
            {getRowModel().rows.map((row) => (
              <Table.Row>
                {row.getVisibleCells().map((cell) => (
                  <Table.Cell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </Table.Cell>
                ))}
              </Table.Row>
            ))}
          </Table.Body>
        </Table>
      </>
      // Then you could build your own pagination here too I guess? If you really wanted to?
    )
  }}
</DataTable>

Server-side pagination and sorting

DataTable can be used with local pagination or server-side pagination (getting only the data needed for the current page). To use one or the other, you have to use the data or getAsyncData prop respectively. The getAsyncData function accepts an object with the necessary parameters to get the relevant piece of data for the current page and order. All the parameters are optional, with these defaults:

{
  pageIndex: 0,
  pageSize: 10,
  sortBy: undefined,
  sortDirection: undefined,
  globalFilter: ''
}

The response from the getAsyncData function must match the following schema:

{
  results: Array<Record<string, unknown>> // your current page data, sorted if specified
  total: number // the total number of elements in your data
}

A loading state using <DataTable.Loading> is automatically included in DataTable which is visible while the getAsyncData promise is pending. You can use DataTable.Error to display your own error component when the getAsyncData function promise rejects. Notice: DataTable.Error doesn't render anything on its own, but whatever is passed as children.

<DataTable
columns={columns}
defaultPageSize={10}
defaultSort={{ column: 'name', direction: 'asc' }}
initialState={{ pagination: { pageIndex: 0, pageSize: 10 } }}
getAsyncData={async ({
  pageIndex,
  pageSize,
  sortBy,
  sortDirection,
  globalFilter
}) => {
  const params = new URLSearchParams({
    page: pageIndex,
    pageSize,
    order: sortBy,
    dir: sortDirection,
    search: globalFilter
  })
  const response = await fetch(`https://your-api?${params.toString()}`)
  const { results, total } = await response.json()

  return { results, total }
}}
>
<DataTable.Table sortable className="mb-4 min-w-125" />
<DataTable.Error>
  {(retry) => <Button onClick={retry}>Try again</Button>}
</DataTable.Error>
<DataTable.Pagination />
</DataTable>

DataTable.Errorprovides aretryfunction to the children, which allows you to recall thegetAsyncDatafunction. Theretryfunction can be called with all the paginated parameters as an optional object. If no parameters are provided,retrywill be called with the last paginated options.

 <DataTable.Error>
  {(retry) => (
    <Button
      onClick={() =>
        retry?.({
          pageIndex: 5,
          pageSize: 10,
          sortBy: 'name',
          sortDirection: 'asc',
          globalFilter: ''
        })
      }
    >
      Retry
    </Button>
  )}
</DataTable.Error> 

Features

Search

DataTable.GlobalFilter renders a search input that filters the whole table by matching the input against values from any table column.

Note:

If a column is rendering a value that isn't directly accessible on the data (i.e.: nested in an object {name: {first: 'a'}} the value needs to be converted to a string in the accessor. For example:

 columnHelper.accessor((row) => String(row.first), {
        header: 'first',
        id: 'firstname',
        cell: (row) => (row.getValue())
      }),

Sorting

A DataTable's data can be sorted by default and can also be sortable by the user. These two options are independent of each other.

Default sorting

DataTable takes an optional defaultSort prop to configure the column and direction for the table's default sorting, e.g. {column: 'name', direction: 'asc'}

User sorting

If DataTable's isSortable state is true, then DataTable.Header will be clickable to toggle between ascending, descending and no sorting in any sortable columns. DataTable.Head and DataTable.Table take an optional boolean sortable prop to configure this option.

Pagination

DataTable.Pagination can be passed as a child to DataTable to render the pagination UI and configure the parent DataTable to paginate its data. The UI will be visible only if there is more than one page in the DataTable

Drag and drop

The DataTable.DragAndDropTable can be rendered in place of DataTable.Table to allow users to reorder table rows via drag and drop. It takes an optional onDragAndDrop prop which is a function that fires when rows have been re-ordered via drag-and-drop. Use this to sync those changes with external data sources. Note that column sorting conflicts with drag and drop behaviour. In any context where you allow drag-and-drop reordering, you probably want to disable column sorting (see User Sorting above). Similarly, you should probably disable pagination because users won't be able to drag rows across page boundaries.

Row IDs

Drag-and-drop functionality relies on each table row having a unique ID. DataTable.DragAndDropContainer will throw an error if you don't provide unique IDs for each row in the data provided to DataTable, so you should consider wrapping your table in an ErrorBoundary to reduce the impact on your user if there is a problem with your data. By default, DataTable.DragAndDropContainer will look for this id in an id property on each object in data. You can use the idColumn prop to provide the name of a different property, e.g. userId, that already exists on your data, so you don't have to generate new IDs just for the table. For example, you could provide data like this with no additional configuration:

const data = [
  { name: 'chrissy', hobby: 'bare-knuckle boxing', id: 1 },
  { name: 'agatha', hobby: 'crossfit', id: 2 },
  { name: 'betty', hobby: 'acting', id: 3 }
]
<DataTable data={data} columns={columns}>
  <DataTable.DragAndDropTable onDragAndDrop={(oldIndex, newIndex, newData) => console.log(oldIndex, newIndex, newData)}/>
</DataTable>

Or you could provide this data and specify the id column accordingly:

const data = [
  { name: 'chrissy', hobby: 'bare-knuckle boxing', userId: 1 },
  { name: 'agatha', hobby: 'crossfit', userId: 2 },
  { name: 'betty', hobby: 'acting', userId: 3 }
]
<DataTable data={data} columns={columns}>
  <DataTable.DragAndDropTable idColumn="userId" onDragAndDrop={(oldIndex, newIndex, newData) => console.log(oldIndex, newIndex, newData)} />
</DataTable>

Row selection and bulk actions

The component supports multiple-row selection. To activate the feature, `enableRowSelection` needs to be passed into the `DataTable` provider. This will activate an extra column containing checkboxes, one for each row, and a main one that toggles all-row selection.

In addition to this, you will also need to build the `BulkActions` bar, which will be composed by using 2 subcomponents, which represent the 2 possible states that relate to row selection:\

  • The `DefaultActions` component will be rendered when no rows are currently being selected
  • The `SelectedRowActions` component will be rendered when at least one row is currently selected.

The difference between the 2 is the fact that the `SelectedRowActions` component contains a default `Cancel` button which simply deselects everything and returns the table to the 'default' state.

Both components can take custom elements, like buttons or links, with custom actions. This allows us to do whatever we want to with the currently selected data. For instance, the `DefaultActions` component could contain a button that adds extra rows to the table. The `SelectedRowActions` component could, instead, contain a button that deletes the selected rows.

Here's what it would all look like when put together:

const columnHelper = createColumnHelper<{
  firstName: string
  lastName: string
  age: string
}>()

const columns = [
  columnHelper.accessor('firstName', {
    header: 'First name',
    id: 'firstName',
    cell: (data) => data.getValue()
  }),
  columnHelper.accessor('lastName', {
    header: 'Last name',
    id: 'lastName',
    cell: (data) => data.getValue()
  }),
  columnHelper.accessor('age', {
    header: 'Age',
    id: 'age',
    cell: (data) => data.getValue()
  })
]

const data = [
  {
    firstName: 'John',
    lastName: 'Doe',
    age: 34
  },
  {
    firstName: 'Jane',
    lastName: 'Doe',
    age: 33
  },
  {
    firstName: 'Tina',
    lastName: 'Doe',
    age: 2
  }
]

const TableHead = () => {
  const { rowSelection, setData } = useDataTable()

  const handleIncreaseAge = () => {
    const selectedRows = Object.keys(rowSelection)

    const updatedData = data.results.map((row, index) => {
      if (selectedRows.includes(index.toString())) {
        row.age += 10
      }
      return row
    })

    setData((current) => ({
      ...current,
      results: updatedData
    }))
  }
  
  return (
    <DataTable.BulkActions>
      <DataTable.BulkActions.DefaultActions>
        <Button onClick={() => console.log('clicked')}>Add row</Button>
      </DataTable.BulkActions.DefaultActions>
      <DataTable.BulkActions.SelectedRowActions>
        <Button onClick={handleIncreaseAge}>Make older</Button>
      </DataTable.BulkActions.SelectedRowActions>
    </DataTable.BulkActions>
  )
}
<DataTable columns={columns} data={data} enableRowSelection>
  <TableHead />
  <DataTable.Table />
</DataTable>

Scrolling behaviour

You might need to display your table on smaller devices. Or you may have a lot of data loaded at once in your table. Or you might have a large table that you wish to display on smaller screens.

In either case, one possible solution would be to add scrolling behaviour. Horizontally or vertically. Or both.

You can pass scrollOptions as a prop to enable either. scrollOptions is an object which can have a number of key-value pairs, as follows:

{
  hasStickyHeader: true,
  headerCss: {
    top: '100px',
    zIndex: 12  
  },
  numberOfStickyColumns: 2,
  scrollContainerCss: {
    height: '250px'
  }
}

hasStickyHeader

If you want your table to have a sticky header row (one that 'sticks' to the top of the viewport as you scroll down - very useful for tables with many rows and columns), you can add hasStickyHeader: true to the scrollOptions object.

headerCss

hasStickyHeader should be enough for simple use cases, but if you maybe have another sticky element above the table, and will need some sort of offset on your table's header, there's also headerCss which you can use. Add this and feel free to customise the header's CSS.

numberOfStickyColumns

If your table has many columns and you wish to make sure it displays nicely on smaller screens, one option would be to have some columns which 'stick' to the left side of the table and have the rest scroll horizontally. Keep in mind that if you are also using enableRowSelection, your table will have an extra column, containing the selection checkbox. So if you wish for your first data column to stick, you will need to pass numberOfStickyColumns: 2.

scrollContainerCss

Sometimes you might have a table with many columns and rows, and you might also wish to have it display nicely on smaller devices. In this case, you will perhaps think about adding both hasStickyHeader and numberOfStickyColumns. The problem is that when numberOfStickyColumns is passed along side hasStickyHeader, the 'stickyness' of the header is lost. We're going to need to add a fixed height to our table, which will now be wrapped in a scrollbox. Use scrollContainerCss for this.

Keep in mind that this will add a vertical scrollbar to the table.

Here's how your DataTable component might look like:

<DataTable columns={columns} data={data}>
  <DataTable.Table 
    scrollOptions={{
      hasStickyHeader: true,
      headerCss: {
        top: '100px',
        zIndex: 10
      },
      numberOfStickyColumns: 2,
      scrollContainerCss: {
        height: '250px'
      }  
    }}
  />
</DataTable>

Subrows and row expansion

You may wish to render your data as nested rows. Maybe you have a list of classes and students, and wish to render each student nested under their respective class. To achieve this, all you need to do is structure your data so that the nested rows are added as an array under the `subRows` property of the `data` object that is then passed into the table provider.

Structuring the data this way will enable `Chevron` icons next to the first column. These are clickable and upon clicking them, they rotate 90 degrees, indicating whether or not the row is expanded. You can have many nesting levels.

const columnHelper = createColumnHelper()

const columns = [
  columnHelper.accessor('name', {
    header: 'Name',
    id: 'name',
    cell: (data) => data.getValue() || ''
  }),
  columnHelper.accessor('age', {
    header: 'Age',
    id: 'age',
    cell: (data) => data.getValue() || ''
  })
]

const data = [
  {
    name: 'John',
    age: 30,
    subRows: [
      {
        name: 'Jim',
        age: 12
      },
      {
        name: 'Jane',
        age: 5
      }
    ]
]
    
<DataTable data={data} columns={columns} enableRowSelection>
  <DataTable.Table />
</DataTable>

Disabled rows

We can display disabled rows with different layout so users can see the difference between those rows and the enabled ones. To achieve this you need to pass a property `disabledRows` that is a Record where the key is the row id (you can get this from the data table hook) and the value is a boolean that if it's true it will show the row as disabled. Note that this is just a visual change, it doesn't disable clickable elements in the table.

const columnHelper = createColumnHelper()

const columns = [
  columnHelper.accessor('name', {
    header: 'Name',
    id: 'name',
    cell: (data) => data.getValue() || ''
  }),
  columnHelper.accessor('age', {
    header: 'Age',
    id: 'age',
    cell: (data) => data.getValue() || ''
  })
]

const data = [
  {
    name: 'John',
    age: 30
  },
  {
    name: 'Mark',
    age: 30
  },
  {
    name: 'Anne',
    age: 30
  }
]
    
<DataTable data={data} columns={columns} disabledRows={{ '0': true }}>
  <DataTable.Table />
</DataTable>

Grouped headers

We can display grouped headers by nesting columns with the group function in the column helper. The group function allows you to pass a header text and a list of columns that will be displayed for that group. By doing that you will have two rows in the table header and the group header will have a colspan equals the number of items in the columns list. Note that if you build a column list with a standalone column and a group column the header of the standalone column will be repeated in the table header, to avoid that be sure to add all columns to a group even when you don't want to show a different header for the group with only one column.

const columnHelper = createColumnHelper()

const columns = \[
  columnHelper.group({
    id:'nameGroup',
    header: '',
    columns: [
      columnHelper.accessor('name', {
        id: 'name',
        header: 'Name',
        cell: (cellContext) => cellContext.getValue()
      })
    ]
  }),
  columnHelper.group({
    id:'overall',
    header: 'Overall',
    columns: \[
      columnHelper.accessor('overallAttainmentScore', {
        id: 'attainment',
        header: 'Attainment',
        cell: (cellContext) => cellContext.getValue()
      }),
      columnHelper.accessor('overallCognitiveScore', {
        id: 'cognitive',
        header: 'Cognitive',
        cell: (cellContext) => cellContext.getValue()
      }),
      columnHelper.accessor('difference', {
        id: 'diff',
        header: 'difference',
        cell: (cellContext) => cellContext.getValue()
      }),
    ]
  }),
]

const data = \[
  { 
    name: 'chrissy', 
    overallAttainmentScore: 104, 
    overallCognitiveScore: 108, 
    difference: -4 
  },
  { 
    name: 'agatha', 
    overallAttainmentScore: 104, 
    overallCognitiveScore: 108, 
    difference: -4
  },
  { 
    name: 'betty', 
    overallAttainmentScore: 104, 
    overallCognitiveScore: 108, 
    difference: -4
  },
  { 
    name: 'denise', 
    overallAttainmentScore: 104, 
    overallCognitiveScore: 108, 
    difference: -4
  },
  { 
    name: 'charlie', 
    overallAttainmentScore: 104, 
    overallCognitiveScore: 108, 
    difference: -4
  },
  { 
    name: 'xena', 
    overallAttainmentScore: 104, 
    overallCognitiveScore: 108, 
    difference: -4
  },
]

<DataTable data={data} columns={columns}>
  <DataTable.Table />
</DataTable>

Row actions

Add an action when clicking the entire row. Any interactive elements such as buttons, inputs take priority over the row action.

const handleRowClick = (
    row: Record<string, unknown>,
    event: React.MouseEvent
  ) => {
    console.log('CUSTOM ROW ACTION', row.name)
  }

<DataTable data={data} columns={columns}>
  <DataTable.Table rowAction={handleRowClick} />
</DataTable>