Table is for many records of one shape, compared down a column. Most settings live on each column. Set the row key yourself, and check the layout when you add scrolling.
When to use
Use a table for many records of the same shape, when people compare one field down a column. If they read one item at a time, it is not a table.
The records share their fields and the question compares them: which deploy is slowest, which invoice is unpaid.
People act on a row or a set of rows: open one, select several, expand one for detail.
The set is long enough to sort, filter or page, and each record fits on a line.
Use something else when
The records vary too much for one set of columns, like a feed or search results. Use a List.
There is one record. Use Descriptions.
The question is a trend, a share or an outlier. Use a chart, with a table under it for the exact numbers.
People navigate a hierarchy rather than compare it. Use a Tree, or a Table with expandable.childrenColumnName if it still needs columns.
Two columns and four rows is a layout, not a table. A header row, a pager and sorting add nothing at that size.
Columns, dataIndex and render
Most of what Table does (sort, filter, pin, group, truncate, hide at a breakpoint) is set on each column, not on the table.
dataIndex reads the value: a key, or an array for a nested path like ["owner", "name"].
render(value, record, index) decides what the cell shows. Its first argument is the value dataIndex picked out, so without a dataIndex it is undefined and you read from record.
Give a column a key when it has no dataIndex or shares one with another column. Sort and filter state is stored under it, so two columns with one key share one sort.
title is the header text, and on a sortable column it is also the cell's accessible name. A title that is only an icon leaves that name empty.
ellipsis truncates instead of wrapping, and it switches the whole table to a fixed layout, which changes how every column is sized.
responsive hides the column below the breakpoints you list. On a phone that often beats scrolling sideways.
A grouped header is a column with children. Sorters and filters work on the nested columns too.
A cell spans by returning { children, props: { colSpan, rowSpan } } from render. Use onCell for anything besides the content, like a class, a click handler or a title.
rowKey, and why the index is the wrong one
rowKey defaults to "key", so a record with no key field has no identity. Set it first.
Point it at your own id: rowKey="id", or rowKey={(record) => record.id} when the id is computed.
Without it, every row gets the same key and a warning in development, and selection and expansion stop working.
An index is wrong too. Sorting, filtering and paging reorder the rows, so row 3 becomes a different record while the selection still says 3.
The key has to survive a refetch, so do not derive it from render order.
rowSelection.preserveSelectedRowKeys keeps selected keys for records that left dataSource, which only works with a stable key.
Sorting and filtering
Both work on the data you passed until you make them remote. To do that, pass true instead of a function and handle the result yourself.
sorter: (a, b) => … sorts the rows you passed. With sorter: { compare, multiple } on several columns, they sort together and the higher multiple decides first.
sortDirections sets the orders a header offers. The default is ["ascend", "descend"], and a third click clears the sort. Give it one value to offer one order.
A local filter is a filters array plus onFilter. Use filterMode: "tree" for nested options, filterSearch for a long list and filteredValue to control it from outside.
For remote data, set sorter: true and filters with no onFilter, then read everything from onChange. extra.action is "paginate", "sort" or "filter".
Once one of the three is remote, make all three remote. Set pagination.total from the server, with one page in dataSource.
Do not also sort in a useMemo over the state onChange writes. With two owners of the order, the sort arrow can point at a different column than the rows are sorted by.
Selection
rowSelection is an object. Its type is "checkbox" by default, or "radio" for single select.
Control it with both selectedRowKeys and onChange, or leave both out. Passing only one causes stale selections.
getCheckboxProps(record) holds per-row rules. Disable the rows that cannot be acted on, and say why in a tooltip.
selections adds a menu beside the header checkbox, built from Table.SELECTION_ALL, SELECTION_INVERT, SELECTION_NONE or your own items. Its arrow icon is Ant Design's and does not follow your icon pack.
Without preserveSelectedRowKeys, selecting across pages drops the rows no longer in dataSource.
Table.SELECTION_COLUMN places the checkbox column somewhere other than first, columnWidth sizes it and fixed pins it.
Pair a selection with a bar that shows how many rows are selected and offers the actions.
Expandable rows
expandable.expandedRowRender(record) opens a panel under the row, across every column. Use it for detail that does not fit a cell.
rowExpandable hides the expand button on rows with nothing to show.
For nested rows, give records a children array and name it with expandable.childrenColumnName. Child rows then sit in the same columns instead of a panel.
expandRowByClick makes the whole row the toggle. It clashes with links and checkboxes in the row, so pick one owner for the row click.
The top-level expandedRowRender, expandIcon and indentSize props are deprecated in favor of the same keys inside expandable. The API table lists them struck through.
expandable.expandIcon can also be set on ConfigProvider, to change it for every table.
Fixed headers, fixed columns and scroll
scroll changes how the table lays out and puts the header in its own box, so it changes more than any other prop.
scroll={{ x: "max-content" }} lets the table grow past its container and scroll sideways. Column width stays a hint there.
A numeric x with a pinned column, or any column with ellipsis, switches to a fixed layout, where width holds.
scroll={{ y }} keeps the header in place inside a box of that height. sticky keeps it at the top of the viewport instead, which suits a long page: no inner scrollbar and no height to guess.
column.fixed: "start" | "end" pins a column. Pin the identifying column and the actions, since each pinned column takes width from the rest.
The header and body are measured and kept in step, so content that changes size after the first paint can leave them slightly out of line.
A body that scrolls sideways cannot be reached from the keyboard on its own. The Accessibility tab has the fix.
Pagination or virtual scrolling
pagination is on by default at 10 rows a page, and it takes the full Pagination config, like showSizeChanger, showTotal and placement.
For server paging, set total and current yourself with one page in dataSource. Without total, the pager counts the rows it was given and shows one page.
pagination={false} turns the pager off. Do that only for a set you know is small.
virtual renders only the rows in view. It needs scroll with both x and y.
Rows outside the view are not in the DOM, so find in page, select all and a screen reader's read-through stop at its edge.
Use virtual when a view must hold thousands of rows at once, like a log. Use a pager everywhere else: it is cheaper, it can be linked to, and it shows how much there is.
Loading, size and borders
loading takes a boolean or Spin props, so loading={{ spinning, tip }} can say what is loading.
Keep the table mounted while it loads. Swapping it for a skeleton loses the scroll position, the sort and the reader's place.
size is large (the default), medium or small. middle still works as the old name for medium.
Use small inside a card or a drawer, and keep one size per surface.