---
title: "DataTable"
description: "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."
---

> Documentation Index
> Fetch the complete documentation index at: https://epidermis.getarbol.com/llms.txt
> Use this file to discover all available pages before exploring further.

# DataTable

## Sorted in the browser

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

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

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

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

```tsx
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

```tsx
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`](https://github.com/getarbol/project-arbol-ui/blob/main/src/components/data-table/data-table.examples.tsx), which the library's tests render too.

Source: https://epidermis.getarbol.com/components/data/data-table/index.mdx
