antd-crud-table
v0.8.1
Published
<div align="center">
Maintainers
Readme
antd-crud-table
A typed, schema-driven CRUD table for React — built on antd and ProComponents.
Describe your columns once and get a paginated, searchable, sortable table with a
create/edit form, delete confirmations, bulk actions and export — wired to static
data, a REST API, localStorage, or anything you implement yourself.
Live demo · Storybook · API reference · Changelog
Why
Most table libraries hand you a grid and leave the CRUD to you. This one takes a column schema and derives the whole surface from it — the cell renderer, the form control, the validation, and the value conversion in both directions.
<CrudTable<User, 'id'>
title="Users"
rowKey="id"
columns={[
{ dataIndex: 'name', title: 'Name', fieldType: 'string', formConfig: { required: true } },
{ dataIndex: 'email', title: 'Email', fieldType: 'email' },
{ dataIndex: 'joined', title: 'Joined', fieldType: 'date' },
]}
hookConfig={{
api: {
baseUrl: '/api',
endpoints: { list: '/users', create: '/users', update: '/users', remove: '/users' },
},
}}
/>That renders a searchable, sortable, paginated table with a working create/edit form, confirmed deletes, and CSV/JSON/Excel export.
Highlights
- Strictly typed.
dataIndexis bound tokeyof T, record ids areT[K], andcustomRender/transformreceive that property's own type. Noanyin the public API. - 20 field types — each one entry in a registry declaring its cell renderer, form control, implied validation and value round-trip.
- Four data strategies behind one
CrudDataSourceinterface: static, REST,localStorage, or your own. Swap backends without touching your columns. - Localized. Every string is overridable, and the table follows the antd
ConfigProvideraround it. - Export to CSV, JSON and Excel, covering the whole filtered result set — with CSV formula injection neutralised.
- Import from CSV,
.xlsand.xlsx: pick a file, map headers onto columns, preview with per-row validation, then create the valid rows. - Almost dependency-free. The heavy things — antd, React, dayjs — are peers you
already have. The only runtime dependency is
to-spreadsheet, which reads.xlsxon import.
Installation
npm install antd-crud-tablepnpm add antd-crud-tablePeer dependencies
| Package | Version |
|---|---|
| react, react-dom | ^18 or ^19 |
| antd | ^6.3.6 |
| @ant-design/icons | ^6 |
| @ant-design/pro-components | ^2.8.10 |
| dayjs | ^1.11.13 |
Stylesheet
The build extracts CSS to a separate file, so import it once — importing the component alone leaves the table unstyled:
import 'antd-crud-table/styles.css';Quick start
import { CrudTable } from 'antd-crud-table';
import type { CrudColumn } from 'antd-crud-table';
import 'antd-crud-table/styles.css';
interface User {
id: number;
name: string;
email: string;
status: 'active' | 'inactive';
joined: string;
}
const columns: CrudColumn<User>[] = [
{ dataIndex: 'name', title: 'Name', fieldType: 'string', formConfig: { required: true } },
{ dataIndex: 'email', title: 'Email', fieldType: 'email' },
{
dataIndex: 'status',
title: 'Status',
fieldType: 'enum',
enumOptions: {
active: { text: 'Active', color: 'green' },
inactive: { text: 'Inactive', color: 'red' },
},
},
{ dataIndex: 'joined', title: 'Joined', fieldType: 'date' },
];
export const Users = () => (
<CrudTable<User, 'id'>
title="Users"
rowKey="id"
columns={columns}
defaultPageSize={10}
enableBulkOperations
hookConfig={{
api: {
baseUrl: '/api',
endpoints: { list: '/users', create: '/users', update: '/users', remove: '/users' },
},
}}
/>
);The second type parameter is the row key. It is what makes ids typed: remove(id)
takes a number here, not any.
Data strategies
Every strategy implements the same CrudDataSource<T, K> interface, so the
columns and behaviour are identical and only the wiring differs.
Static data
hookConfig={{ staticData: users }}In-memory, seeded once. Edits persist for the session. Good for demos, fixtures and tests.
REST API
hookConfig={{
api: {
baseUrl: '/api',
endpoints: { list: '/users', create: '/users', update: '/users', remove: '/users' },
},
}}Endpoints default to /list, /create, /update and /delete under baseUrl,
so a conventional collection needs them stated as above. Paging defaults to
current/pageSize, and updates to PUT {update}/:id. For an API that
speaks a different dialect, paramNames, methods, serializeRequest and
parseResponse cover most of it declaratively — see
REST dialect recipes for offset/limit, Django REST
Framework, JSON:API and auth.
Failures throw RestError, carrying status and body so you can branch on a
422 rather than parsing a message.
localStorage
hookConfig={{ storageKey: 'my-users', initialData: seed }}Persists across reloads and stamps createdAt / updatedAt.
Your own
hookConfig={{
operations: {
list: async (query) => ({ items: await db.find(query), total: await db.count() }),
create: async (draft) => db.insert(draft),
update: async (id, draft) => db.update(id, draft),
remove: async (id) => db.delete(id),
},
}}Omitted operations fail with a message naming what is missing, rather than on
undefined. For full control, construct a CrudDataSource and pass it as
dataSource.
Columns
interface CrudColumnFor<T, K extends keyof T> {
dataIndex: K; // must be a real key of T
title: string; // header, and the form label
fieldType?: FieldType; // defaults to 'string'
enumOptions?: Record<string, EnumOption>; // for 'enum'
customRender?: (value: T[K], record: T) => ReactNode;
formConfig?: {
required?: boolean;
component?: ReactNode; // replace the control entirely
transform?: (value: T[K]) => T[K]; // applied before writing
rules?: FormRule[]; // antd validation rules
};
fieldEditable?: boolean; // default true
searchable?: boolean; // default true
}CrudColumn<T> is the union across every key of T, so an array annotated
CrudColumn<User>[] keeps each column's callbacks bound to its own property type.
Naming a property that does not exist on T is a compile error.
Field types
| Type | Cell | Form control |
|---|---|---|
| string | text | Input |
| textarea | truncated text | Input.TextArea |
| number | locale-grouped | InputNumber |
| money | currency | InputNumber (2 dp) |
| percent | percentage | InputNumber with % |
| boolean | Yes/No tag | Switch |
| enum | coloured tag | Select |
| date | formatted datetime | DatePicker |
| time | time | TimePicker |
| dateRange | start ~ end | RangePicker |
| email | mailto: link | Input + email rule |
| url | external link | Input + url rule |
| password | •••••••• | Input.Password |
| rating | Rate | Rate |
| progress | Progress | InputNumber |
| tags | tag list | tag Select |
| image | thumbnail | Input |
| color | swatch + hex | ColorPicker |
| json | inline code | monospace TextArea |
| custom | your customRender | your formConfig.component |
Each type is one entry in fieldRegistry declaring its renderer, control,
validation and toFormValue/fromFormValue conversion — so a value survives the
round-trip into the edit form and back. Browse them all in the
Storybook.
Security note: password values are masked in the table and excluded from
exports. url, email and image render only http/https (plus mailto:)
targets — a javascript: value renders as inert text rather than a clickable
link.
Options
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | — | Header, and the export filename |
| rowKey | K | — | Identity property |
| columns | CrudColumn<T>[] | — | Column definitions |
| hookConfig | UseCrudTableOptions<T, K> | — | Data strategy |
| defaultPageSize | number | 10 | Rows per page |
| enableBulkOperations | boolean | false | Row selection and bulk delete |
| enableColumnSettings | boolean | true | Column visibility and density |
| enableExport | boolean | true | Export menu entries |
| exportScope | 'all' \| 'page' | 'all' | Whole result set, or visible rows |
| enableImport | boolean | false | Import menu entry (CSV / .xls / .xlsx) |
| importConcurrency | number | 5 | Max simultaneous creates during import |
| customActions | (record, actions) => ReactNode[] | — | Extra row controls |
| locale | PartialCrudTableLocale | English | String overrides |
Localization
The table follows the antd ConfigProvider around it, so setting your app locale
once localizes pagination, date pickers, empty states and the ProTable chrome:
import { ConfigProvider } from 'antd';
import frFR from 'antd/locale/fr_FR';
<ConfigProvider locale={frFR}>
<CrudTable {...config} />
</ConfigProvider>With no ConfigProvider in the tree the table supplies English itself — antd
components otherwise fall back to their own built-in defaults.
The library's own wording comes from the locale prop. Supply only what you want
to change; anything omitted stays English:
<CrudTable
locale={{
actions: 'Aktionen',
edit: 'Bearbeiten',
delete: 'Löschen',
create: 'Neu',
confirmDeleteTitle: 'Wirklich löschen?',
deleteSelected: (count) => `${count} entfernen`,
}}
{...config}
/>Interpolated strings are functions rather than templates with placeholders, so a
translation cannot silently drop a value or reorder its arguments. The full
contract is CrudTableLocale; enUS is the exported default.
Export
The toolbar menu writes the whole filtered result set, not just the visible
page, using the data source's listAll. The labels state which they will do —
Export all as CSV or Export page as CSV when the source cannot list without
pagination. Set exportScope: 'page' to opt out.
| Format | Output |
|---|---|
| csv | .csv, formula-injection safe |
| json | .json, the raw records |
| excel | .xls, Excel 2003 SpreadsheetML |
Cells beginning =, +, -, @, tab or CR are prefixed so spreadsheets treat
them as text. Quoting alone does not prevent evaluation, and row content in a CRUD
table is exactly the untrusted input an attacker controls. Genuine numbers are
left alone.
Excel note:
excelemits SpreadsheetML in a.xlsfile rather than OOXML, which would mean taking on a ZIP implementation. Excel 2016+ shows a format/extension mismatch prompt on open; the file is intact. Use CSV if you need a prompt-free export.
Import
Set enableImport to add an Import entry to the toolbar menu. It opens a
dialog that walks a file in: pick it, map its headers onto columns (auto-matched
on header text, and editable), preview the parsed rows with per-row validation
using the same formConfig.rules the create form uses, then create the valid rows.
<CrudTable<User, 'id'>
title="Users"
rowKey="id"
columns={columns}
hookConfig={{ storageKey: 'users' }}
enableImport
/>| Format | Read via |
|---|---|
| .csv | built-in RFC 4180 parser, no dependency |
| .xls | the Excel 2003 SpreadsheetML this library exports, parsed as plain XML |
| .xlsx | real OOXML, via to-spreadsheet's readExcel |
Behaviour worth knowing:
- Values round-trip. Export then import reproduces the original records. Enum columns accept both the stored key and the exported label.
- Partial success is honest. Rows failing validation are skipped and reported per row; a row that fails to create does not abort the rest.
- Bounded concurrency. A large file does not fire one request per row. A data
source may implement the optional
createMany(drafts)to create server-side in a batch; otherwise creates run through a small pool (importConcurrency, default 5). - Passwords are never imported, the same way they are never exported.
Using the hook directly
useCrudTable works without the table when you want your own UI:
const crud = useCrudTable<User, 'id'>('id', { storageKey: 'users' });
crud.state; // { loading, error, data, total, page, pageSize }
await crud.create({ name: 'Ada' });
await crud.update(1, { name: 'Ada L.' });
await crud.remove(1);onSuccess and onError fire for every operation — including list failures, with
the original Error rather than a flattened string.
Documentation
| | | |---|---| | Live demo | Every strategy, running | | Storybook | One story per field type | | API reference | Generated from the types | | REST recipes | Non-default API dialects | | Migration guide | Upgrading from 0.5.x | | Changelog | Release history |
Development
pnpm install
pnpm dev # demo app
pnpm storybook # component playground
pnpm test # unit and component tests
pnpm test:coverage # with enforced thresholds
pnpm lint
pnpm build:lib # the published package
pnpm build:site # demo + storybook + api referenceContributions are welcome. Tests and lint run on every pull request against both React 18 and 19.
