Skip to content

DataTable

A list driven by TanStack Table v9 and drawn by Table: sortable headers, column sizes, visibility and order, rows that open their record, an empty state, and a dimmed busy state while the next answer loads. Server-sorted lists pass manualSorting.

Language
Appearance

Sorted in the browser

Text columns start A to Z, figures highest first; each press flips the order.

NameChannelConversations
Ana TorresPhone4
Luis GómezWhatsApp12
Marta RuizWeb chat27
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';

interface Contact {
  id: string;
  name: string;
  channel: string;
  conversations: number;
}

const CONTACTS: Contact[] = [
  { id: 'c1', name: 'Luis Gómez', channel: 'WhatsApp', conversations: 12 },
  { id: 'c2', name: 'Ana Torres', channel: 'Phone', conversations: 4 },
  { id: 'c3', name: 'Marta Ruiz', channel: 'Web chat', conversations: 27 },
];

const COLUMNS: ColumnDef<DataTableFeatures, Contact>[] = [
  { accessorKey: 'name', header: 'Name', meta: { fill: true } },
  { accessorKey: 'channel', enableSorting: false, header: 'Channel', size: 140 },
  {
    accessorKey: 'conversations',
    header: 'Conversations',
    size: 150,
    meta: { align: 'end', cellClassName: 'tabular-nums' },
  },
];

<DataTable
  aria-label="Contacts"
  columns={COLUMNS}
  data={CONTACTS}
  defaultSorting={[{ id: 'name', desc: false }]}
  getRowHref={(contact) => `#/contacts/${contact.id}`}
  getRowId={(contact) => contact.id}
/>

Sorted by the server

With manualSorting the rows stay in the order they came in; while the next page loads they dim.

NameChannelConversations
Luis GómezWhatsApp12
Ana TorresPhone4
Marta RuizWeb chat27
import type { ColumnDef, SortingState } from '@tanstack/react-table';
import { useState } from 'react';
import { DataTable } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';

interface Contact {
  id: string;
  name: string;
  channel: string;
  conversations: number;
}

const CONTACTS: Contact[] = [
  { id: 'c1', name: 'Luis Gómez', channel: 'WhatsApp', conversations: 12 },
  { id: 'c2', name: 'Ana Torres', channel: 'Phone', conversations: 4 },
  { id: 'c3', name: 'Marta Ruiz', channel: 'Web chat', conversations: 27 },
];

const COLUMNS: ColumnDef<DataTableFeatures, Contact>[] = [
  { accessorKey: 'name', header: 'Name', meta: { fill: true } },
  { accessorKey: 'channel', enableSorting: false, header: 'Channel', size: 140 },
  {
    accessorKey: 'conversations',
    header: 'Conversations',
    size: 150,
    meta: { align: 'end', cellClassName: 'tabular-nums' },
  },
];

const ServerSortedExample = () => {
  const [sorting, setSorting] = useState<SortingState>([{ id: 'conversations', desc: true }]);
  const [isUpdating, setIsUpdating] = useState(false);
  return (
    <DataTable
      aria-label="Contacts"
      columns={COLUMNS}
      data={CONTACTS}
      getRowId={(contact) => contact.id}
      isUpdating={isUpdating}
      layout="fixed"
      manualSorting
      onSortingChange={(next) => {
        setSorting(next);
        setIsUpdating(true);
        setTimeout(() => {
          setIsUpdating(false);
        }, 1200);
      }}
      sorting={sorting}
    />
  );
};

<ServerSortedExample />

Every state of a list

Inside its frame and under its header: placeholder rows on the first load, a failed load with a retry, nothing yet, and nothing that matches the filters.

NameChannelConversations
Loading…
import { UsersIcon } from '@phosphor-icons/react';
import type { ColumnDef } from '@tanstack/react-table';
import { useState } from 'react';
import { Button, DataTable, EmptyState, SegmentedControl } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';

interface Contact {
  id: string;
  name: string;
  channel: string;
  conversations: number;
}

const COLUMNS: ColumnDef<DataTableFeatures, Contact>[] = [
  { accessorKey: 'name', header: 'Name', meta: { fill: true } },
  { accessorKey: 'channel', enableSorting: false, header: 'Channel', size: 140 },
  {
    accessorKey: 'conversations',
    header: 'Conversations',
    size: 150,
    meta: { align: 'end', cellClassName: 'tabular-nums' },
  },
];

type ListState = 'loading' | 'error' | 'empty' | 'filtered';

const NO_CONTACTS: Contact[] = [];

const StatesExample = () => {
  const [state, setState] = useState<ListState>('loading');
  return (
    <div className="flex flex-col gap-3">
      <SegmentedControl
        label="State"
        onChange={setState}
        options={[
          { label: 'Loading', value: 'loading' },
          { label: 'Failed', value: 'error' },
          { label: 'Nothing yet', value: 'empty' },
          { label: 'No matches', value: 'filtered' },
        ]}
        size="sm"
        value={state}
      />
      <DataTable
        aria-label="Contacts"
        columns={COLUMNS}
        data={NO_CONTACTS}
        emptyState={
          <EmptyState
            actions={<Button>Add contact</Button>}
            description="Import a CSV or add your first contact by hand."
            icon={<UsersIcon aria-hidden />}
            title="No contacts yet"
          />
        }
        errorDescription="Check your connection and try again."
        isError={state === 'error'}
        isFiltered={state === 'filtered'}
        isLoading={state === 'loading'}
        layout="fixed"
        onClearFilters={() => {
          setState('empty');
        }}
        onRetry={() => {
          setState('loading');
        }}
      />
    </div>
  );
};

<StatesExample />

Columns that leave a narrow table

Each column with minTableWidth leaves when the table is narrower, header and cells, and comes back when there is room. Narrow the page to see the channel go first.

NameChannelConversations
Luis GómezWhatsApp12
Ana TorresPhone4
Marta RuizWeb chat27
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';

interface Contact {
  id: string;
  name: string;
  channel: string;
  conversations: number;
}

const CONTACTS: Contact[] = [
  { id: 'c1', name: 'Luis Gómez', channel: 'WhatsApp', conversations: 12 },
  { id: 'c2', name: 'Ana Torres', channel: 'Phone', conversations: 4 },
  { id: 'c3', name: 'Marta Ruiz', channel: 'Web chat', conversations: 27 },
];

const RESPONSIVE_COLUMNS: ColumnDef<DataTableFeatures, Contact>[] = [
  { accessorKey: 'name', header: 'Name', meta: { fill: true } },
  {
    accessorKey: 'channel',
    enableSorting: false,
    header: 'Channel',
    size: 140,
    meta: { minTableWidth: 720 },
  },
  {
    accessorKey: 'conversations',
    header: 'Conversations',
    size: 150,
    meta: { align: 'end', cellClassName: 'tabular-nums', minTableWidth: 480 },
  },
];

<DataTable
  aria-label="Contacts"
  columns={RESPONSIVE_COLUMNS}
  data={CONTACTS}
  getRowId={(contact) => contact.id}
  layout="fixed"
/>

Hidden columns and an empty state

NameConversations

No results for these filters

import type { ColumnDef } from '@tanstack/react-table';
import { Button, DataTable, EmptyState } from '@arbol-ai/ui';
import type { DataTableFeatures } from '@arbol-ai/ui';

interface Contact {
  id: string;
  name: string;
  channel: string;
  conversations: number;
}

const COLUMNS: ColumnDef<DataTableFeatures, Contact>[] = [
  { accessorKey: 'name', header: 'Name', meta: { fill: true } },
  { accessorKey: 'channel', enableSorting: false, header: 'Channel', size: 140 },
  {
    accessorKey: 'conversations',
    header: 'Conversations',
    size: 150,
    meta: { align: 'end', cellClassName: 'tabular-nums' },
  },
];

<DataTable
  aria-label="Contacts"
  columnVisibility={{ channel: false }}
  columns={COLUMNS}
  data={[]}
  emptyState={
    <EmptyState actions={<Button variant="outline">Clear filters</Button>} variant="filtered" />
  }
/>

Source

The examples above are src/components/data-table/data-table.examples.tsx, which the library’s tests render too.

Type to search…

↑↓ navigate↵ selectEsc close