---
name: aircall-blocks/migrate-dashboard/data-table
description: >
  Migrate @dashboard/library LoadMoreTable, LoadMoreStrategy, LoadMoreTableProps,
  and LoadMoreScrollProps to the @aircall/ds DataTable (TanStack Table-backed).
  Load when a file imports LoadMoreTable or LoadMoreStrategy from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
  - aircall-blocks/setup
  - aircall-blocks/migrate-dashboard
sources:
  - "aircall/hydra:packages/ds/src/index.ts"
---

This skill builds on aircall-blocks/migrate-dashboard.

## 1. Architecture change

`LoadMoreTable` from `@dashboard/library` was a self-contained wrapper around Tractor's
`Table` that bolted on a load-more strategy (`button` or `scroll`) and a loading spinner
overlay. Columns were passed as plain descriptor objects (`{ id, label, renderer? }`).

`DataTable` from `@aircall/ds` is built on **TanStack Table v8**. There is no strategy
enum — pagination mode is controlled by a `loadingState` string and an `onFetchMore`
callback. Columns are `ColumnDef<TData>[]` (a bound alias exported from `@aircall/ds`). The component
handles both infinite scroll (via IntersectionObserver sentinel) and initial-load
skeletons natively.

## 2. Target mapping

| `@dashboard/library` | `@aircall/ds` |
|---|---|
| `LoadMoreTable` | `DataTable` |
| `LoadMoreStrategy.Button` | No direct equivalent — use a custom "Load more" button rendered outside `DataTable` (see §4b) |
| `LoadMoreStrategy.Scroll` | `onFetchMore` + `hasMore` (built-in sentinel) |
| `loading={true}` (initial) | `loadingState="loading"` |
| `loadingMore={true}` | `loadingState="loadingMore"` |
| `hasMore` | `hasMore` |
| `onLoadMore` | `onFetchMore` |
| `columns[n].id` | `ColumnDef.accessorKey` (or `id` + `accessorFn`) |
| `columns[n].label` | `ColumnDef.header` |
| `columns[n].renderer` | `ColumnDef.cell: ({ row }) => row.original.<field>` |
| `noDataMessage` | `emptyState` (accepts `ReactNode`) |
| `data-test` | `data-test` (still forwarded via `...props`) |
| `buttonText` / `loadingText` | Not a prop — render your own button outside `DataTable` |
| `scrollThreshold` | Fixed 200 px look-ahead in `DataTable` sentinel — not configurable |
| `h` / flex layout props | Use `fillHeight` prop + flex parent (`flex flex-col min-h-0`) |

## 3. Verified DS exports (`packages/ds/src/index.ts`)

```
DataTable, type ColumnDef, type DataTableLoadingState, type OnChangeFn, type SortingState
Empty, EmptyContent, EmptyTitle, EmptyDescription
Spinner
```

`@aircall/ds` **exposes** the TanStack Table types you author — `ColumnDef`, `SortingState`, `OnChangeFn` — as bound aliases, so **import them from `@aircall/ds`**, not `@tanstack/react-table`. The react-table runtime ships as a regular `@aircall/ds` dependency (auto-installed; `DataTable` owns the table instance internally), so there is nothing to install and no version to keep in sync.

## 4. Before / After

### 4a. Scroll strategy (infinite scroll)

**Before (`@dashboard/library`):**
```tsx
import { useState } from 'react';
import { LoadMoreTable, LoadMoreStrategy } from '@dashboard/library';

type Row = { id: string; name: string; email: string };

const columns = [
  { id: 'name', label: 'Name' },
  { id: 'email', label: 'Email', renderer: ({ email }: Row) => <a href={`mailto:${email}`}>{email}</a> },
];

function PeopleTable() {
  const [data, setData] = useState<Row[]>(initialPage);
  const [loading, setLoading] = useState(false);
  const [loadingMore, setLoadingMore] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const handleLoadMore = async () => {
    setLoadingMore(true);
    const next = await fetchNextPage();
    setData(prev => [...prev, ...next.rows]);
    setHasMore(next.hasMore);
    setLoadingMore(false);
  };

  return (
    <LoadMoreTable
      data={data}
      columns={columns}
      hasMore={hasMore}
      loading={loading}
      loadingMore={loadingMore}
      h="420px"
      onLoadMore={handleLoadMore}
      strategy={LoadMoreStrategy.Scroll}
      noDataMessage="No people found."
      data-test="people-table"
    />
  );
}
```

**After (`@aircall/ds`):**
```tsx
import { useState } from 'react';
import { DataTable, type DataTableLoadingState, Empty, EmptyContent, EmptyTitle } from '@aircall/ds';
import type { ColumnDef } from '@aircall/ds';

type Row = { id: string; name: string; email: string };

const columns: ColumnDef<Row>[] = [
  { accessorKey: 'name', header: 'Name' },
  {
    accessorKey: 'email',
    header: 'Email',
    cell: ({ row }) => <a href={`mailto:${row.original.email}`}>{row.original.email}</a>,
  },
];

function PeopleTable() {
  const [data, setData] = useState<Row[]>(initialPage);
  const [loadingState, setLoadingState] = useState<DataTableLoadingState>('idle');
  const [hasMore, setHasMore] = useState(true);

  const handleFetchMore = async () => {
    setLoadingState('loadingMore');
    const next = await fetchNextPage();
    setData(prev => [...prev, ...next.rows]);
    setHasMore(next.hasMore);
    setLoadingState('idle');
  };

  return (
    <div className="flex flex-col min-h-0" style={{ height: 420 }}>
      <DataTable
        data={data}
        columns={columns}
        getRowId={row => row.id}
        loadingState={loadingState}
        hasMore={hasMore}
        onFetchMore={handleFetchMore}
        fillHeight
        emptyState={
          <Empty>
            <EmptyContent>
              <EmptyTitle>No people found</EmptyTitle>
            </EmptyContent>
          </Empty>
        }
        data-test="people-table"
      />
    </div>
  );
}
```

Key changes:
- `loading` / `loadingMore` booleans collapse into a single `loadingState` discriminated string
- `strategy={LoadMoreStrategy.Scroll}` + `onLoadMore` → `onFetchMore` + `hasMore` (sentinel is built-in)
- Column descriptors (`{ id, label, renderer }`) → `ColumnDef<TData>[]` with `accessorKey`, `header`, and `cell`
- `h="420px"` (Tractor Flex prop) → wrap in a flex container with a bounded height + pass `fillHeight`
- `noDataMessage` string → `emptyState` ReactNode

> **Row virtualization (large accumulated lists).** For an infinite-scroll table that
> piles up many pages, also pass `virtualizeRows` so only visible rows are in the DOM
> (requires `fillHeight`). **Tradeoff:** `virtualizeRows` implies `fixedLayout`
> (`table-layout: fixed`) — every column then takes its `size`, and columns with no
> `size` fall back to TanStack's **150px** default. A table that relied on content-based
> auto widths (e.g. a narrow icon column) will visibly snap to 150px columns. Add an
> explicit `size` to each `ColumnDef` to keep the intended widths, and check them in the
> browser (`tsc`/tests won't catch a width regression). See
> `@aircall/ds#aircall-ds/migrate-tractor/data-table` §8 for details.

### 4b. Button strategy

`LoadMoreStrategy.Button` has no direct prop equivalent in `DataTable`. Render the
"Load more" button below the table instead:

**Before (`@dashboard/library`):**
```tsx
import { LoadMoreTable, LoadMoreStrategy } from '@dashboard/library';

<LoadMoreTable
  data={data}
  columns={columns}
  hasMore={hasMore}
  loading={loading}
  loadingMore={loadingMore}
  buttonText="Load more"
  loadingText="Loading…"
  onLoadMore={handleLoadMore}
  strategy={LoadMoreStrategy.Button}
/>
```

**After (`@aircall/ds`):**
```tsx
import { DataTable, type DataTableLoadingState, Spinner, Button } from '@aircall/ds';
import type { ColumnDef } from '@aircall/ds';

// Button is from @aircall/ds; import it alongside DataTable.
<div className="flex flex-col gap-4">
  <DataTable
    data={data}
    columns={columns}
    loadingState={loadingState === 'loading' ? 'loading' : 'idle'}
  />
  {hasMore && (
    <div className="flex justify-center">
      <Button
        variant="outline"
        onClick={handleLoadMore}
        disabled={loadingState === 'loadingMore'}
      >
        {loadingState === 'loadingMore' ? <Spinner /> : 'Load more'}
      </Button>
    </div>
  )}
</div>
```

Key changes:
- `buttonText` / `loadingText` / `loadingMore` are not props on `DataTable` — drive the
  button's label and disabled state from your own `loadingState` variable
- `Spinner` and `Button` are imported from `@aircall/ds` (same package as `DataTable`)

### 4c. Initial loading state

**Before (`@dashboard/library`):**
```tsx
import { LoadMoreTable } from '@dashboard/library';

<LoadMoreTable
  data={data ?? null}
  columns={columns}
  hasMore={false}
  loading={isLoading}
  onLoadMore={() => {}}
/>
```

**After (`@aircall/ds`):**
```tsx
import { DataTable } from '@aircall/ds';
import type { ColumnDef } from '@aircall/ds';

<DataTable
  data={data ?? []}
  columns={columns}
  loadingState={isLoading ? 'loading' : 'idle'}
/>
```

Key changes:
- `data` is always `TData[]`; pass `[]` while loading (skeleton rows replace the empty body)
- `loading={true}` → `loadingState="loading"` shows skeleton rows automatically
- No `hasMore` or `onLoadMore` needed for a non-paginated table

## 5. Common mistakes

### Mistake 1 — Passing `strategy` or `onLoadMore` to `DataTable`

```tsx
// Wrong — DataTable has no strategy or onLoadMore prop; they are silently ignored
import { DataTable } from '@aircall/ds';

<DataTable
  data={data}
  columns={columns}
  strategy="scroll"
  onLoadMore={handleLoadMore}
/>

// Correct — use onFetchMore for scroll-based pagination
<DataTable
  data={data}
  columns={columns}
  hasMore={hasMore}
  onFetchMore={handleFetchMore}
/>
```

`LoadMoreStrategy` and `onLoadMore` are `@dashboard/library` concepts. `DataTable` exposes
`onFetchMore` (triggers when the IntersectionObserver sentinel enters the viewport) and
`hasMore` (hides the sentinel when false). There is no button-strategy prop — render your
own button outside the table for that UX pattern.

Source: `packages/ds/src/components/data-table.tsx`

### Mistake 2 — Keeping `loading` / `loadingMore` as separate boolean props

```tsx
// Wrong — DataTable has no loading or loadingMore props; they are silently ignored
<DataTable
  data={data}
  columns={columns}
  loading={isLoading}
  loadingMore={isFetchingMore}
/>

// Correct — collapse both booleans into a single loadingState string
<DataTable
  data={data}
  columns={columns}
  loadingState={isLoading ? 'loading' : isFetchingMore ? 'loadingMore' : 'idle'}
/>
```

DS replaced the dual-boolean pattern with a discriminated `loadingState` string so each
async phase renders the right UX: `'loading'` shows skeleton rows (initial load), while
`'loadingMore'` appends a spinner row at the bottom (pagination). Passing `loading` or
`loadingMore` as props is silently ignored and the table will not show any loading indicator.

Source: `packages/ds/src/components/data-table.tsx`

### Mistake 3 — Using column descriptor objects instead of `ColumnDef`

```tsx
// Wrong — { id, label, renderer } is the @dashboard/library TableColumn shape;
// DataTable does not read these fields
import { DataTable } from '@aircall/ds';

const columns = [
  { id: 'name', label: 'Name' },
  { id: 'email', label: 'Email', renderer: ({ email }) => <a href={`mailto:${email}`}>{email}</a> },
];

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

// Correct — use ColumnDef from @aircall/ds
import type { ColumnDef } from '@aircall/ds';

type Row = { id: string; name: string; email: string };

const columns: ColumnDef<Row>[] = [
  { accessorKey: 'name', header: 'Name' },
  {
    accessorKey: 'email',
    header: 'Email',
    cell: ({ row }) => <a href={`mailto:${row.original.email}`}>{row.original.email}</a>,
  },
];
```

`DataTable` is built on TanStack Table v8. It reads `ColumnDef` fields (`accessorKey`,
`header`, `cell`, `enableSorting`, etc.) — not `id`, `label`, or `renderer`. Passing the
old descriptor shape compiles (TypeScript can't narrow the column union tightly), but
all columns will render blank cells because no accessor is registered.

Source: `packages/ds/src/components/data-table.tsx`

### Mistake 4 — Passing `noDataMessage` instead of `emptyState`

```tsx
// Wrong — noDataMessage is a @dashboard/library / Tractor prop; DataTable ignores it
<DataTable data={[]} columns={columns} noDataMessage="No records found." />

// Correct — emptyState accepts any ReactNode; use the Empty compound for rich states
import { DataTable, Empty, EmptyContent, EmptyTitle, EmptyDescription } from '@aircall/ds';

<DataTable
  data={[]}
  columns={columns}
  emptyState={
    <Empty>
      <EmptyContent>
        <EmptyTitle>No records found</EmptyTitle>
        <EmptyDescription>Try adjusting your filters.</EmptyDescription>
      </EmptyContent>
    </Empty>
  }
/>
```

`DataTable` requires an `emptyState` prop; `noDataMessage` is not defined on `DataTable`
and is silently ignored — the table body renders nothing when data is empty. For plain
text wrap it in `<p className="text-sm text-muted-foreground">`. For a full empty state
with an icon and call-to-action, compose with the `Empty` family from `@aircall/ds`.

Source: `packages/ds/src/components/data-table.tsx`

### Mistake 5 — Passing height as a Tractor prop instead of using `fillHeight`

```tsx
// Wrong — h / w are Tractor Flex props; DataTable does not accept them
<DataTable data={data} columns={columns} h="420px" />

// Correct — constrain the parent height with a flex container and pass fillHeight
<div className="flex flex-col min-h-0" style={{ height: 420 }}>
  <DataTable data={data} columns={columns} fillHeight />
</div>
```

`LoadMoreTable` forwarded layout props (`h`, `w`, `flex`, etc.) to the Tractor `Flex`
wrapper it rendered internally. `DataTable` has no such props. Use `fillHeight` to pin
the header and scroll the body internally — the parent must provide a bounded height via
a flex column (`flex flex-col` + `min-h-0`). Without a bounded parent, `fillHeight` has
no effect and the table grows to its natural height.

Source: `packages/ds/src/components/data-table.tsx`
