DataTable

A data table rendered from column and row data, with sorting, row selection, pagination, and the loading and empty states built in.
Columns are plain header labels or descriptors ({ label, numeric, width, sortable, ... }) for per-column alignment, fixed widths, and sorting; compose a toolbar above when the screen needs one. Density tweaks affect padding live. Pass onRowEdit / onRowDelete for a trailing actions column: the pencil opens the row's string cells as fields with Save/Cancel (committing via onRowCommit), and Delete asks for a confirming second press before it fires. inlineEdit skips the pencil and lets a string cell be pressed straight into a field, committing via onCellCommit. onRowPress makes the whole row act on press and puts the keyboard and screen-reader path on a button in the row's first column, so keep your own controls out of that column when you use it. Data stays yours either way: the table reports intent and re-renders whatever rows it is handed back.
Treat rows and columns as immutable inputs. When editing data, replace the outer rows array and each changed row, as the examples below do. When changing a column, replace its descriptor and the columns array. Keep unchanged arrays and callbacks stable for large tables: Canvas caches sorting, paging, and selection summaries so typing a draft or opening a row action does not process the whole data set again. sortValue and rowKey must be pure functions of their inputs. If either closes over changing external data, supply a new callback identity; for sortValue, also replace its column descriptor and the columns array. A saved edit appears in sorted order when the updated rows arrive.
On narrow web and Android layouts, the columns still share the table width and wrap their text, each keeping a 110 px minimum (a column with a fixed width keeps that width), and they pan horizontally only when those minimums exceed the table width. A long cell wraps inside its column rather than widening the table. The overflowing scrollport is a keyboard tab stop, so the platform's arrow keys can reveal the remaining columns, and while it has keyboard focus the table's surface draws the theme's focus ring. A table that fits adds no scrolling tab stop, and on Android it takes no sideways drag either, so a tap that drifts sideways still presses its row and a swipe that starts across it scrolls the page (while TalkBack explores by touch the scroller stays enabled, so the exploring finger reaches every cell). On an iPhone-width table iOS shows only the first column instead, as SwiftUI Table does. A table that passes stacks does neither: at that width each row stacks its cells (see Stacked below). On the web and Android, and for a table that stacks on any platform, crossing that width changes the layout only: rows, custom cells, an open editor with its text and focus, and a windowed body's scroll position stay as they are when a window is resized across it or a phone measures the table on its first frame. The iOS first-column layout is the exception: below that width the columns after the first leave the table, with any editor or custom cell in them, and mount again when it widens.
A virtualized table's body scrolls its rows under the fixed header. On the web, while those rows overflow, the body is a keyboard tab stop of its own (after the column scroller when a narrow table pans, and before the rows' controls when they have any), the keyboard can scroll it, and while it has keyboard focus the table's surface draws the same focus ring. The body is the table's row group, so the rows it renders stay inside the table for a screen reader rather than behind an unnamed focusable node.
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
iOS
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Android
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Web
tsx
<DataTable
columns={["Name", "Email", "Role"]}
rows={[
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"],
["Rachel Chen", "rachel@example.com", "Admin"]
]}
/>

Props

Prop
Description
columns
required
Array<string | DataTableColumn>
Columns: a header label per column, or a `DataTableColumn` descriptor for per-column alignment (`numeric`/`centered`), a fixed `width`, and sorting (`sortable`, `sortValue`). Strings and descriptors mix freely. Treat the array and descriptors as immutable: replace both when a column changes.
rows
required
ReactNode[][]
Row data: an array of rows, each an array of cells (one per column). A cell is a string (rendered in the default cell type) or any ReactNode for a custom cell, such as a link, a `Badge`, or a monospace name (rendered directly). Treat this data as immutable: replace the outer array and each changed row when data changes, so cached sorting and pagination update correctly.
striped
boolean
Tint every other data row for easier horizontal scanning.
bordered
boolean
Wrap the table in a rounded outer border.
attached
boolean
The table sits flush inside a frame its parent draws (a `flush` Card, a bordered panel that clips to its corners), so the header band squares its corners to that frame instead of floating as a rounded band; `bordered` does the same for the table's own outline. Without a frame the band keeps its rounded corners, which is the standalone look.
stacks
boolean
Responsive: when the table's own CONTAINER is at or below `stackBreakpoint` (default `sm` = 640), each row lays its cells out top to bottom at the full width instead of side by side, so long text reads at the width of the table rather than a column's share of it. The first cell leads the row as its title; every later cell carries its column label above its value. The header row is drawn only while it holds a control (a sortable column, or the box that selects every row), as a band of its labels that wraps; the selection box and the row actions stay at the row's leading and trailing edges. A stacked table never pans, and on iOS it stacks instead of collapsing to its first column. Container-measured with a viewport seed for the first frame, like Row `stacks`; crossing the breakpoint changes the layout only.
stackBreakpoint
BreakpointKey
The breakpoint at and below which `stacks` stacks the rows (default `"sm"`). Only meaningful with `stacks` (DEV warns without it).
compact
boolean
Tighter vertical padding on header and data cells.
comfortable
boolean
Looser vertical padding on header and data cells.
sortable
boolean
Make every column sortable: pressing a header cycles ascending, descending, off. A column descriptor opts back out with `sortable: false`. Sorting is self-managed out of the box; control it via `sort`/`onSortChange`.
sort
DataTableSort | null
Controlled sort state (`null` = unsorted). Omit for uncontrolled use.
defaultSort
DataTableSort | null
Initial sort for uncontrolled use.
onSortChange
(sort: DataTableSort | null) => void
Fired with the next sort (or `null` when cleared) on header press.
selectable
boolean
Row selection: a leading checkbox column with a select-all header checkbox (indeterminate on a partial selection). Selection is keyed by `rowKey` (the row's original index when omitted) and self-managed out of the box; control it via `selectedKeys`/`onSelectionChange`. When no `onRowPress` is set, pressing anywhere on a row toggles it.
selectedKeys
Array<string | number>
Controlled selected row keys. Omit for uncontrolled use.
defaultSelectedKeys
Array<string | number>
Initial selected row keys for uncontrolled use.
onSelectionChange
(keys: Array<string | number>) => void
Fired with the next selected keys when a row or the select-all is toggled.
paginated
boolean
Page the rows through a built-in footer (the kit Pagination): a "Showing X-Y of N" range with Prev/Next, plus the selection count when `selectable`. Sorting applies before paging. Self-managed out of the box; control the page via `page`/`onPageChange`.
pageSize
number
Rows per page (CONTROLLED; default 10, or the first of `pageSizes`).
defaultPageSize
number
Initial rows per page for uncontrolled use.
pageSizes
number[]
Selectable rows-per-page values. Supplying them upgrades the footer to the Pagination `withSize` variant (a "Rows per page" selector).
onPageSizeChange
(size: number) => void
Fired with the next rows-per-page value when the footer selector changes it.
page
number
Current page, 1-based (CONTROLLED). Omit for uncontrolled use.
defaultPage
number
Initial page for uncontrolled use.
onPageChange
(page: number) => void
Fired with the next 1-based page when the footer pagination navigates.
loading
boolean
Loading state: the body renders pulsing skeleton placeholder rows (one per would-be row on the page) instead of data.
emptyMessage
ReactNode
What to render, centered under the header, when `rows` is empty (and not `loading`). A string gets the muted caption type; any ReactNode renders as given (an `EmptyState`, a retry Button). Omitted = just the header row, as before.
onRowPress
(row: ReactNode[], index: number) => void
When set, each data row is pressable, reporting the row data and its index in `rows`. A press anywhere on the row acts; the keyboard and screen-reader path is a button inside the row's FIRST column, named from the row's plain text cells (or, when it has none, by that cell's own content). The row itself stays a `row`, so keep your own controls out of the first column here: a control there would nest inside the activator.
onRowEdit
(rowIndex: number) => void
Row editing: passing this adds a trailing actions column with a pencil button per row. Pressing the pencil opens the row's edit mode (its string cells become fields with a trailing Save/Cancel pair) and fires this with the row's original index in `rows`. Save reports the edited cells through `onRowCommit`; Cancel (or Escape in a field) closes without committing.
onRowDelete
(rowIndex: number) => void
Row deletion: passing this adds a delete (bin) button to the trailing actions column. Delete is second-press confirm: the first press ARMS it (the bin turns destructive and its accessible name says confirm), a second press within the confirm window fires this with the row's original index, and pressing anywhere else in the table (or the window lapsing) disarms.
inlineEdit
boolean
Let string cells be edited in place: pressing one swaps it to a field. Enter or blur commits through `onCellCommit`; Escape restores the cell. Custom ReactNode cells (badges, links) are not editable.
onCellCommit
(rowIndex: number, colIndex: number, next: string) => void
Fired when an `inlineEdit` cell commits (Enter or blur) with a changed value: the row's original index in `rows`, the column index, and the next text. Data stays yours: re-render with updated `rows` to keep the edit.
onRowCommit
(rowIndex: number, cells: ReactNode[]) => void
Fired when a pencil-opened row edit is saved: the row's original index in `rows` and the full cells array with the edited strings in place (custom ReactNode cells pass through unchanged).
virtualized
boolean
Render the data rows through a windowed `FlatList` instead of mounting every row up front, for large tables. The header row stays fixed above it. Give the table a bounded height (via `style`, e.g. `{ maxHeight: 400 }`) so the body can scroll; without one it warns and renders eagerly. Default mounts all rows.
rowKey
(row: ReactNode[], index: number) => string | number
Stable key for a row, derived from the row data and its index in `rows`. Selection is keyed by it, and it keeps React row identity stable when rows reorder (sorting, live data) and a cell is a stateful custom ReactNode. When omitted, rows key off their original array index (safe for string/number cells and stateless custom cells; selection then tracks row POSITIONS, so supply a real key when the data itself can be reordered, inserted, or deleted). Keep this function pure; replace its identity when external data changes how keys are derived, so the cached selection summary updates.
testID
string
E2E hook forwarded to the root element.
style
LayoutStyle
Composition within a parent only, never a restyle hook and never a width: the parent layout container provides the bounds.

Do & Don’t

default

Don’t
Name
Email
Alice Johnson
alice@example.com
Bob Smith
bob@example.com
Rachel Chen
rachel@example.com
Wiring search but dropping the footer leaves the user with no result count or way to page through 142 rows.
Do
Name
Email
Alice Johnson
alice@example.com
Bob Smith
bob@example.com
Rachel Chen
rachel@example.com
Showing 1-3 of 5
Use the built-in paginated footer so the search result is always anchored to the total.

bulk

Don’t
3 selected
Surfacing only the destructive Delete on a selection invites accidental data loss with no safer path.
Do
2 selected
Name
Email
Alice Johnson
alice@example.com
Bob Smith
bob@example.com
Rachel Chen
rachel@example.com
Pair the selection with a bulk bar that leads with the non-destructive action and keeps Delete visually distinct.

filter

Don’t
Status:
A filter control with no result count leaves the user guessing whether the filter narrowed anything.
Do
Status:
142 results
Echo the live result count next to the filter so its effect is visible.

empty

Don’t
Name
Email
Status
Hiding the body entirely on no results collapses the table and looks broken.
Do
Name
Email
Status
No results found.
Keep the header and let the built-in emptyMessage span a centered row so the structure stays intact.

loading

Don’t
Name
Email
Status
Loading…
A bare "Loading…" string gives no sense of progress and reads like static content.
Do
Name
Email
Status
Use the built-in loading skeletons so the load reads as the table taking shape in place.