# Table States

The rows that show the loading, empty and error states of a table.

```tsx
import * as React from 'react'
import {
  Table,
  TableBody,
  TableCell,
  TableErrorRow,
  TableHead,
  TableHeader,
  TableMessageRow,
  TableRow,
  TableSkeletonRows,
  ToggleGroup,
  ToggleGroupItem,
} from 'ferry-ui'

const INVOICES = [
  { number: 'INV-2041', amount: '$1,250.00' },
  { number: 'INV-2042', amount: '$3,480.00' },
]
const ERROR = new Error('The request failed with status 502.')

export default function TableStatesHero() {
  const [state, setState] = React.useState('loading') // In an app, the request that loads the rows gives the state.
  return (
    <div className="flex flex-col gap-3">
      <ToggleGroup
        type="single"
        variant="outline"
        aria-label="State of the table"
        value={state}
        onValueChange={(next) => next && setState(next)}
      >
        <ToggleGroupItem value="loading">Loading</ToggleGroupItem>
        <ToggleGroupItem value="error">Error</ToggleGroupItem>
        <ToggleGroupItem value="empty">Empty</ToggleGroupItem>
        <ToggleGroupItem value="data">Data</ToggleGroupItem>
      </ToggleGroup>
      <Table aria-label="Invoices">
        <TableHeader>
          <TableRow className="hover:bg-transparent">
            <TableHead>Invoice</TableHead>
            <TableHead className="text-right">Amount</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody aria-busy={state === 'loading'}>
          {state === 'loading' && <TableSkeletonRows columns={2} rows={2} />}
          {state === 'error' && <TableErrorRow colSpan={2} error={ERROR} onRetry={() => setState('loading')} />}
          {state === 'empty' && <TableMessageRow colSpan={2}>No invoices yet.</TableMessageRow>}
          {state === 'data' &&
            INVOICES.map((invoice) => (
              <TableRow key={invoice.number}>
                <TableCell className="font-mono text-[13px]">{invoice.number}</TableCell>
                <TableCell className="text-right tabular">{invoice.amount}</TableCell>
              </TableRow>
            ))}
        </TableBody>
      </Table>
    </div>
  )
}
```

## Usage guidelines

- **Keep the header in each state.** Put these rows in the `TableBody` of the real [Table](/docs/components/table). The user then sees the columns of the table.
- **Skeleton rows are for the first load.** For a refresh, keep the rows that are on the screen.
- **Keep the old rows after a refresh error.** Put a `StaleDataCallout` above the table. See [Callout](/docs/components/callout).
- **Not outside a table.** For an empty area, use [Empty State](/docs/components/empty-state). For a placeholder, use [Skeleton](/docs/components/skeleton).

## Anatomy

Import the rows. Show one of them in the body of the table for each state.

```tsx title="Anatomy"

  Table,
  TableBody,
  TableErrorRow,
  TableHeader,
  TableMessageRow,
  TableRow,
  TableSkeletonRows,
} from 'ferry-ui'

<Table>
  <TableHeader />
  <TableBody aria-busy={loading}>
    {loading ? (
      <TableSkeletonRows columns={4} />
    ) : error ? (
      <TableErrorRow colSpan={4} error={error} onRetry={retry} />
    ) : rows.length === 0 ? (
      <TableMessageRow colSpan={4}>No invoices yet.</TableMessageRow>
    ) : (
      rows.map((row) => <TableRow key={row.id} />)
    )}
  </TableBody>
</Table>
```

| Part | State | Role |
| --- | --- | --- |
| `TableSkeletonRows` | The first load | Placeholder rows, with one bar in each cell. |
| `TableMessageRow` | No rows | One row with a centered message. |
| `TableErrorRow` | The load failed | One row with the error message. It can show a Retry button. |

Give the number of columns of the table to `columns` and to `colSpan`.

## Examples

### Loading

`TableSkeletonRows` shows placeholder rows. `rows` sets how many. The default is 4.

```tsx
import { Table, TableBody, TableHead, TableHeader, TableRow, TableSkeletonRows } from 'ferry-ui'

export default function TableStatesLoading() {
  return (
    <Table aria-label="Members">
      <TableHeader>
        <TableRow className="hover:bg-transparent">
          <TableHead>Name</TableHead>
          <TableHead>Email</TableHead>
          <TableHead>Role</TableHead>
          <TableHead className="text-right">Projects</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody aria-busy="true">
        <TableSkeletonRows columns={4} rows={3} />
      </TableBody>
    </Table>
  )
}
```

### Empty table

`TableMessageRow` shows one message across the table. Use it when the table has no rows.

```tsx
import { Table, TableBody, TableHead, TableHeader, TableMessageRow, TableRow } from 'ferry-ui'

export default function TableStatesEmpty() {
  return (
    <Table aria-label="API keys">
      <TableHeader>
        <TableRow className="hover:bg-transparent">
          <TableHead>Name</TableHead>
          <TableHead>Key</TableHead>
          <TableHead>Created</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableMessageRow colSpan={3}>No API keys yet. Create a key to call the API from your code.</TableMessageRow>
      </TableBody>
    </Table>
  )
}
```

### No match

When no row matches the search or the filters, tell the user. Put a small button in the message row to clear them.

```tsx
import * as React from 'react'
import {
  Button,
  SearchInput,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableMessageRow,
  TableRow,
} from 'ferry-ui'
import { X } from 'lucide-react'

const CUSTOMERS = [
  { name: 'Acme', plan: 'Pro' },
  { name: 'Maya Chen', plan: 'Free' },
  { name: 'Jonas Weber', plan: 'Enterprise' },
]

export default function TableStatesNoMatch() {
  const [query, setQuery] = React.useState('orders')
  const rows = CUSTOMERS.filter((customer) => customer.name.toLowerCase().includes(query.trim().toLowerCase()))

  return (
    <div className="flex flex-col gap-4">
      <SearchInput placeholder="Search customers" value={query} onValueChange={setQuery} />
      <Table aria-label="Customers">
        <TableHeader>
          <TableRow className="hover:bg-transparent">
            <TableHead>Customer</TableHead>
            <TableHead>Plan</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.length === 0 ? (
            <TableMessageRow colSpan={2}>
              <div className="flex flex-col items-center gap-2">
                <span>No customers match “{query}”.</span>
                <Button size="tiny" icon={<X />} onClick={() => setQuery('')}>
                  Clear search
                </Button>
              </div>
            </TableMessageRow>
          ) : (
            rows.map((customer) => (
              <TableRow key={customer.name}>
                <TableCell>{customer.name}</TableCell>
                <TableCell className="text-foreground-light">{customer.plan}</TableCell>
              </TableRow>
            ))
          )}
        </TableBody>
      </Table>
    </div>
  )
}
```

### Load error

`TableErrorRow` shows the message of `error`. With `onRetry`, it also shows a Retry button. Set `retrying` while the new load is in progress.

```tsx
import * as React from 'react'
import { Table, TableBody, TableErrorRow, TableHead, TableHeader, TableRow } from 'ferry-ui'

const ERROR = new Error('Could not reach the orders service.')

export default function TableStatesError() {
  const [retrying, setRetrying] = React.useState(false)

  // In an app, `onRetry` starts the request again.
  function retry() {
    setRetrying(true)
    window.setTimeout(() => setRetrying(false), 1500)
  }

  return (
    <Table aria-label="Orders">
      <TableHeader>
        <TableRow className="hover:bg-transparent">
          <TableHead>Order</TableHead>
          <TableHead>Customer</TableHead>
          <TableHead className="text-right">Total</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableErrorRow colSpan={3} error={ERROR} onRetry={retry} retrying={retrying} />
      </TableBody>
    </Table>
  )
}
```

### Clickable rows

`rowLinkProps(onOpen)` makes a full row clickable. Spread its result on a `TableRow`. A click on a link, a button or a field in the row does not call `onOpen`.

```tsx
import * as React from 'react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, rowLinkProps } from 'ferry-ui'

const MEMBERS = [
  { id: 'maya', name: 'Maya Chen', role: 'Owner' },
  { id: 'jonas', name: 'Jonas Weber', role: 'Admin' },
  { id: 'priya', name: 'Priya Patel', role: 'Member' },
]

export default function TableStatesRowLink() {
  // In an app, the row and the link go to the page of the member.
  const [opened, setOpened] = React.useState<string>()

  return (
    <div className="flex flex-col gap-3">
      <Table aria-label="Members">
        <TableHeader>
          <TableRow className="hover:bg-transparent">
            <TableHead>Name</TableHead>
            <TableHead>Role</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {MEMBERS.map((member) => (
            <TableRow key={member.id} {...rowLinkProps(() => setOpened(member.name))}>
              <TableCell>
                <a
                  href={`#${member.id}`}
                  onClick={(event) => {
                    event.preventDefault()
                    setOpened(member.name)
                  }}
                  className="rounded-sm outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
                >
                  {member.name}
                </a>
              </TableCell>
              <TableCell className="text-foreground-light">{member.role}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
      <p className="text-[13px] text-foreground-light" aria-live="polite">
        {opened ? `Opened: ${opened}` : 'Click a row to open a member.'}
      </p>
    </div>
  )
}
```

## Accessibility

- Screen readers do not see the skeleton rows. Set `aria-busy` on the `TableBody` while they show.
- The message of `TableErrorRow` has the `alert` role. Screen readers read it when it shows.
- A row click is for the pointer only. Keep a real link in the first cell for the keyboard and for screen readers.

## API reference

Each component also accepts the attributes of the `<tr>` element.

### TableSkeletonRows

Each placeholder row gets the other attributes. Thus the component does not accept `ref` and `id`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns` (required) | `number` |  | Number of cells per row: the number of columns of the table (`<TableHead>` count). |
| `rows` | `number` | `4` | Number of placeholder rows. Match the expected page size when known, capped to a few rows. Defaults to 4. |
| `className` | `string` |  | Classes merged onto every placeholder `<TableRow>`. |

### TableMessageRow

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colSpan` (required) | `number` |  | Number of columns the single cell spans: the number of columns of the table. |
| `children` (required) | `ReactNode` |  | The message (and, if useful, a small action such as a "Clear filters" link button). Wraps when long. |
| `tone` | `"destructive" \| "neutral" \| "muted"` | `neutral` | `neutral` (default) = lighter foreground for empty states; `destructive` = red text for failures (the tone vocabulary of the library). Prefer `TableErrorRow` for load errors. `muted` is a deprecated alias of `neutral`. |
| `className` | `string` |  | Classes merged onto the full-width `<TableCell>` (e.g. `h-32` for a taller empty area). |

### TableErrorRow

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `colSpan` (required) | `number` |  | Number of columns the single cell spans: the number of columns of the table. |
| `error` (required) | `unknown` |  | What was thrown or rejected; its message is shown through `getErrorMessage`. |
| `onRetry` | `(() => void)` |  | Shows a small Retry button under the message that calls it. |
| `retrying` | `boolean` |  | Spinner on the Retry button while the new attempt runs. |
| `retryLabel` | `ReactNode` | `Retry` | Label of the Retry button. Defaults to "Retry". |
| `className` | `string` |  | Classes merged onto the full-width `<TableCell>`. |

### rowLinkProps

`rowLinkProps(onOpen)` returns `className` and `onClick` for a `TableRow`. A `className` after the spread replaces the `className` of the result. Merge the two with [cn](/docs/utilities/cn).

| Name | Type | Role |
| --- | --- | --- |
| `onOpen` | `(event: React.MouseEvent) => void` | The argument. It runs when the user clicks the row. |
| `className` | `string` | In the result. It sets the pointer cursor. |
| `onClick` | `(event: React.MouseEvent) => void` | In the result. It calls `onOpen` for a click on the row. |
