Ant Design Table настройка почти всегда оказывается больше работы, чем кажется на старте: вставить компонент и вывести массив легко, а сделать колонки читаемыми, фильтры рабочими без лишних перерендеров и пагинацию, которая тянет данные с бэкенда, а не грузит всё разом, уже требует понимания API компонента. Я собирал такие таблицы для разных админок, от списка заказов, синхронизированных из WooCommerce, до дашборда со статусами доставки СДЭК на сотнях строк. Ниже рабочие паттерны без пересказа документации ради количества слов.
Структура Table: columns, dataSource и rowKey
Любая таблица в Ant Design держится на трёх вещах: массиве columns с описанием колонок, массиве dataSource с данными и rowKey, по которому React отличает строки друг от друга. Если rowKey не задан или указывает на несуществующее поле, компонент подставит индекс массива, и при сортировке или обновлении данных строки начнут «прыгать» и терять внутреннее состояние (например, раскрытые фильтры).
import { Table } from 'antd';
const columns = [
{ title: 'Имя', dataIndex: 'name', key: 'name' },
{ title: 'Email', dataIndex: 'email', key: 'email' },
{ title: 'Статус', dataIndex: 'status', key: 'status' },
];
function UsersTable({ data, loading }) {
return (
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
/>
);
}
export default UsersTable;
Если идентификатор записи лежит не в поле id, а, скажем, в _id или составном ключе, rowKey можно передать функцией: rowKey={(record) => record.orderNumber}. Это дешевле, чем потом ловить баг с пропадающими чекбоксами в rowSelection.
Настройка колонок таблицы: render, сортировка, ширина и фиксация
reader колонки через dataIndex выводит сырое значение, и почти сразу нужен render: отформатировать сумму, дату, статус бейджиком. sorter добавляет сортировку по клику на заголовок: это либо функция сравнения на клиенте, как compareFunction у Array.sort, либо true, если сортировку считает бэкенд, либо объект вида { compare, multiple }, если нужна сортировка сразу по нескольким колонкам. fixed закрепляет колонку слева или справа при горизонтальном скролле, что критично, когда колонок за пределами экрана десяток и больше.
const columns = [
{
title: 'Заказ',
dataIndex: 'orderNumber',
key: 'orderNumber',
fixed: 'left',
width: 120,
},
{
title: 'Сумма',
dataIndex: 'amount',
key: 'amount',
sorter: (a, b) => a.amount - b.amount,
render: (value) => `${value.toLocaleString('ru-RU')} ₽`,
},
{
title: 'Дата',
dataIndex: 'createdAt',
key: 'createdAt',
sorter: (a, b) => new Date(a.createdAt) - new Date(b.createdAt),
render: (value) => new Date(value).toLocaleDateString('ru-RU'),
},
];
Для таблиц с большим числом столбцов добавляю scroll={{ x: 1200 }} на сам Table - иначе колонки сжимаются до нечитаемой ширины на узких экранах. Тянуть колонку мышью и менять её ширину руками сам Table из коробки не умеет, но в официальных примерах Ant Design есть готовый рецепт на основе react-resizable, если такой функционал реально нужен пользователям админки.
Когда таблица вырастает из одного компонента в полноценный раздел с ролями, авторизацией и историей изменений, обычно проще сразу закладывать это в разработку CRM-панели на React, а не наращивать один Table до размеров отдельного приложения.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
Фильтры в Ant Design Table: встроенные и с поиском по тексту
Простой случай - фильтр по фиксированному набору значений: статус заказа, роль пользователя, регион. Для этого в колонке достаточно указать filters и onFilter, и Ant Design сам нарисует выпадающий список с чекбоксами над колонкой.
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
filters: [
{ text: 'Оплачен', value: 'paid' },
{ text: 'В обработке', value: 'processing' },
{ text: 'Отменён', value: 'cancelled' },
],
onFilter: (value, record) => record.status === value,
}
Это работает на клиенте, то есть фильтрует уже загруженный dataSource. Если данные приходят постранично с сервера, onFilter не спасёт - фильтр нужно прокидывать в параметры запроса из onChange таблицы и делать выборку на бэкенде.
Для текстового поиска по колонке (имя клиента, номер телефона, артикул) использую filterDropdown с полем ввода вместо списка чекбоксов - это штатный паттерн из документации Ant Design, не самодельное решение поверх компонента.
import { Input, Button, Space } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
const getColumnSearchProps = (dataIndex) => ({
filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }) => (
<div style={{ padding: 8 }}>
<Input
placeholder={`Поиск по ${dataIndex}`}
value={selectedKeys[0]}
onChange={(e) => setSelectedKeys(e.target.value ? [e.target.value] : [])}
onPressEnter={() => confirm()}
style={{ marginBottom: 8, display: 'block' }}
/>
<Space>
<Button type="primary" onClick={() => confirm()} size="small">
Найти
</Button>
<Button onClick={() => clearFilters()} size="small">
Сбросить
</Button>
</Space>
</div>
),
filterIcon: (filtered) => (
<SearchOutlined style={{ color: filtered ? '#1677ff' : undefined }} />
),
onFilter: (value, record) =>
record[dataIndex]
?.toString()
.toLowerCase()
.includes(value.toLowerCase()),
});
Если поиск идёт не по уже загруженным строкам, а дёргает API на каждое нажатие клавиши, добавляю debounce на 300-400 миллисекунд перед вызовом confirm или перед самим запросом - без этого на среднем сервере с базой в несколько сотен тысяч строк поиск ощутимо тормозит и генерирует лишнюю нагрузку.
Пагинация: клиентская и серверная настройка
По умолчанию Table сам режет dataSource на страницы по 10 записей и рисует пагинацию снизу. Для админки обычно добавляю showSizeChanger (выбор количества строк на странице), showTotal (сколько всего записей) и showQuickJumper для быстрого перехода на страницу по номеру.
Проблема начинается, когда записей не полторы сотни, а десятки тысяч: грузить их все в браузер и резать на клиенте бессмысленно, страница будет открываться заметно дольше и займёт лишнюю память. Тут разница между двумя подходами становится ощутимой.
| Критерий | Клиентская пагинация | Серверная пагинация |
|---|---|---|
| Где хранятся данные | Весь массив в памяти браузера | На бэкенде, отдаются постранично |
| Разумный объём | До нескольких тысяч строк | Десятки тысяч и больше |
| Фильтрация и сортировка | Мгновенно, средствами Table | Через параметры запроса к API |
| Нагрузка на сервер | Один запрос при открытии страницы | Запрос на каждое действие: страница, фильтр, сортировка |
Серверную пагинацию настраиваю через связку pagination (объект current, pageSize, total) и onChange, где приходят пагинация, фильтры и сортировка одним вызовом.
import { useEffect, useState } from 'react';
import { Table } from 'antd';
function OrdersTable() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });
const fetchData = (params = {}) => {
setLoading(true);
const query = new URLSearchParams({
page: params.current ?? pagination.current,
pageSize: params.pageSize ?? pagination.pageSize,
...(params.sortField ? { sortField: params.sortField, sortOrder: params.sortOrder } : {}),
});
fetch(`/api/orders?${query.toString()}`)
.then((res) => res.json())
.then((result) => {
setData(result.items);
setPagination((prev) => ({
...prev,
current: params.current ?? prev.current,
pageSize: params.pageSize ?? prev.pageSize,
total: result.total,
}));
})
.finally(() => setLoading(false));
};
useEffect(() => {
fetchData();
}, []);
const handleTableChange = (newPagination, filters, sorter) => {
fetchData({
current: newPagination.current,
pageSize: newPagination.pageSize,
sortField: sorter.field,
sortOrder: sorter.order,
});
};
return (
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={pagination}
onChange={handleTableChange}
/>
);
}
Важный момент: total в pagination обязательно берётся из ответа сервера, а не из длины текущего массива data - иначе Table решит, что данных всего одна страница, и кнопки листания просто пропадут.
Иногда данные в такую таблицу приходят не напрямую из основной базы, а из внешнего источника - например, воркфлоу в n8n раз в несколько минут забирает заказы из CRM и складывает в промежуточную таблицу, которую и читает API админки. Для отображения это ничего не меняет, но объясняет, почему total иногда обновляется с задержкой.
Производительность и мелочи, которые всплывают в проде
sticky прокидывает шапку таблицы так, что она остаётся видна при скролле длинного списка - удобно, когда строк на странице полсотни и больше, а колонок штук пятнадцать. Для действительно больших наборов данных (тысячи строк без пагинации, например, лог событий с фильтром по дате) у Table есть проп virtual: он включает рендер только видимых строк вместо всех сразу, что заметно снижает нагрузку на DOM. Вместе с ним обязательно задать и scroll.x, и scroll.y, причём числами, иначе виртуальный скролл не включится.
Отдельная тема - полная перерисовка таблицы при смене состояния. Это устройство компонента, а не ошибка: Table не знает, какое состояние использовано внутри columns.render, и перерисовывается целиком, чтобы данные не разошлись. Обёртка columns в useMemo эту перерисовку не отменяет, для контроля рендера ячеек в документации предлагают column.shouldCellUpdate. Стабильный массив columns полезен сам по себе, чтобы не пересобирать описание колонок на каждый рендер, но выигрыша в перерисовке таблицы от него ждать не стоит.
Экспорта в Excel у самого Table нет - для выгрузки обычно подключаю библиотеку вроде xlsx на клиенте или делаю отдельный эндпоинт на бэкенде, который отдаёт готовый файл, если данных много и генерировать их в браузере накладно.
React регулярно обновляется, поэтому перед внедрением я сверяюсь с актуальной документацией.
Частые вопросы
Как сделать поиск сразу по нескольким колонкам в Ant Design Table?
Штатного поиска по нескольким колонкам одной строкой в Table нет. Обычно делаю отдельное поле поиска над таблицей, которое на onChange фильтрует dataSource по нескольким полям через include (для клиентских данных) либо уходит отдельным параметром q в запрос к API, если поиск серверный.
Почему пагинация Ant Design Table сбрасывается на первую страницу?
При фильтрации это штатное поведение: после фильтра число страниц обычно уменьшается, и Table по умолчанию возвращает пользователя на первую страницу, чтобы текущая не оказалась за пределами выборки. Отличить этот случай от остальных помогает поле action в объекте extra у onChange. Если же страница сбрасывается не при фильтрации, обычная причина в том, что pagination передаётся не управляемым объектом из состояния, а вычисляется заново при каждом рендере без current из предыдущего запроса.
Можно ли зафиксировать шапку таблицы при скролле?
Да, для этого есть проп sticky у Table - он закрепляет заголовок (а при необходимости и скролл-бар) при прокрутке длинной таблицы, без дополнительных стилей и обёрток.
Стоит ли использовать Ant Design Table для очень больших датасетов без пагинации?
Если строк реально много (несколько тысяч и больше) и постраничный вывод по бизнес-логике не подходит, включаю virtual и задаю scroll.x и scroll.y числами, как требует документация, это снимает основную часть нагрузки на рендер. Но для отчётов и логов чаще всё же выгоднее серверная пагинация или подгрузка порциями при скролле, чем держать весь массив в памяти браузера.