Core Table Hook
TheuseTable hook from @refinedev/core is the foundation:
import { useTable } from "@refinedev/core";
const {
tableQuery: { data, isLoading },
current,
setCurrent,
pageSize,
setPageSize,
filters,
setFilters,
sorters,
setSorters,
} = useTable({
resource: "posts",
pagination: { current: 1, pageSize: 10 },
sorters: { initial: [{ field: "createdAt", order: "desc" }] },
filters: { initial: [{ field: "status", operator: "eq", value: "published" }] },
});
Quick Start
# For TanStack Table
npm install @refinedev/react-table @tanstack/react-table
# For Ant Design
npm install @refinedev/antd antd
# For Material UI
npm install @refinedev/mui @mui/x-data-grid
# For Mantine
npm install @refinedev/mantine @mantine/core
TanStack Table (Headless)
import { useTable } from "@refinedev/react-table";
import { flexRender } from "@tanstack/react-table";
const PostList = () => {
const columns = [
{
id: "id",
header: "ID",
accessorKey: "id",
},
{
id: "title",
header: "Title",
accessorKey: "title",
},
{
id: "status",
header: "Status",
accessorKey: "status",
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<button onClick={() => edit("posts", row.original.id)}>
Edit
</button>
),
},
];
const {
reactTable: {
getHeaderGroups,
getRowModel,
getState,
setPageIndex,
getPageCount,
getCanPreviousPage,
getCanNextPage,
previousPage,
nextPage,
},
} = useTable({ columns });
return (
<div>
<table>
<thead>
{getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
<div>
<button
onClick={() => previousPage()}
disabled={!getCanPreviousPage()}
>
Previous
</button>
<span>
Page {getState().pagination.pageIndex + 1} of {getPageCount()}
</span>
<button
onClick={() => nextPage()}
disabled={!getCanNextPage()}
>
Next
</button>
</div>
</div>
);
};
Ant Design
import { useTable } from "@refinedev/antd";
import { Table, Space } from "antd";
import { EditButton, ShowButton, DeleteButton } from "@refinedev/antd";
const PostList = () => {
const { tableProps } = useTable();
return (
<Table {...tableProps} rowKey="id">
<Table.Column dataIndex="id" title="ID" />
<Table.Column dataIndex="title" title="Title" sorter />
<Table.Column dataIndex="status" title="Status" />
<Table.Column
title="Actions"
render={(_, record) => (
<Space>
<EditButton size="small" recordItemId={record.id} />
<ShowButton size="small" recordItemId={record.id} />
<DeleteButton size="small" recordItemId={record.id} />
</Space>
)}
/>
</Table>
);
};
Material UI
import { useDataGrid } from "@refinedev/mui";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { EditButton, ShowButton, DeleteButton } from "@refinedev/mui";
const PostList = () => {
const { dataGridProps } = useDataGrid();
const columns: GridColDef[] = [
{ field: "id", headerName: "ID", width: 70 },
{ field: "title", headerName: "Title", flex: 1 },
{ field: "status", headerName: "Status", width: 120 },
{
field: "actions",
headerName: "Actions",
renderCell: ({ row }) => (
<>
<EditButton hideText recordItemId={row.id} />
<ShowButton hideText recordItemId={row.id} />
<DeleteButton hideText recordItemId={row.id} />
</>
),
},
];
return (
<DataGrid
{...dataGridProps}
columns={columns}
autoHeight
/>
);
};
Pagination
Server-Side Pagination
const { current, setCurrent, pageSize, setPageSize, pageCount } = useTable({
pagination: {
current: 1,
pageSize: 10,
mode: "server", // Default
},
});
return (
<div>
<Table {...props} />
<div>
<button
onClick={() => setCurrent(current - 1)}
disabled={current === 1}
>
Previous
</button>
<span>Page {current} of {pageCount}</span>
<button
onClick={() => setCurrent(current + 1)}
disabled={current === pageCount}
>
Next
</button>
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
</div>
);
Client-Side Pagination
Fetch all data and paginate in browser:const { tableQuery, current, setCurrent } = useTable({
pagination: {
mode: "client",
pageSize: 10,
},
});
// All records are fetched once
// Pagination happens in the browser
Infinite Scroll
import { useInfiniteList } from "@refinedev/core";
const PostList = () => {
const {
data,
isLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteList({
resource: "posts",
pagination: {
pageSize: 20,
},
});
const allPosts = data?.pages.flatMap((page) => page.data) || [];
return (
<div>
{allPosts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</button>
)}
</div>
);
};
Sorting
Initial Sort
const { sorters, setSorters } = useTable({
sorters: {
initial: [
{ field: "createdAt", order: "desc" },
{ field: "title", order: "asc" },
],
},
});
Interactive Sorting
const { sorters, setSorters } = useTable();
const handleSort = (field: string) => {
const existing = sorters.find((s) => s.field === field);
if (!existing) {
setSorters([{ field, order: "asc" }]);
} else if (existing.order === "asc") {
setSorters([{ field, order: "desc" }]);
} else {
setSorters([]);
}
};
return (
<table>
<thead>
<tr>
<th onClick={() => handleSort("title")}>
Title
{sorters.find((s) => s.field === "title")?.order === "asc" && " ↑"}
{sorters.find((s) => s.field === "title")?.order === "desc" && " ↓"}
</th>
</tr>
</thead>
</table>
);
Permanent Sort
const { sorters } = useTable({
sorters: {
permanent: [
// These sorters cannot be changed by the user
{ field: "isArchived", order: "asc" },
],
initial: [
// These can be changed
{ field: "createdAt", order: "desc" },
],
},
});
Filtering
Basic Filters
const { filters, setFilters } = useTable({
filters: {
initial: [
{ field: "status", operator: "eq", value: "published" },
{ field: "category.id", operator: "eq", value: "1" },
],
},
});
// Add a filter
setFilters([
...filters,
{ field: "author", operator: "eq", value: "john" },
]);
// Replace all filters
setFilters(
[{ field: "status", operator: "eq", value: "draft" }],
"replace"
);
// Remove a filter (set value to undefined)
setFilters([
...filters.filter((f) => f.field !== "status"),
]);
Filter Operators
const operators = [
"eq", // Equal
"ne", // Not equal
"lt", // Less than
"lte", // Less than or equal
"gt", // Greater than
"gte", // Greater than or equal
"in", // In array
"nin", // Not in array
"contains", // Contains string
"ncontains", // Doesn't contain
"containss", // Contains (case-sensitive)
"ncontainss", // Doesn't contain (case-sensitive)
"null", // Is null
"nnull", // Is not null
"between", // Between two values
"nbetween", // Not between
"startswith", // Starts with
"nstartswith", // Doesn't start with
"endswith", // Ends with
"nendswith", // Doesn't end with
];
Search Filter
import { useState } from "react";
import { CrudFilters } from "@refinedev/core";
const PostList = () => {
const [search, setSearch] = useState("");
const { setFilters } = useTable();
const handleSearch = (value: string) => {
setSearch(value);
const filters: CrudFilters = value
? [
{
field: "title",
operator: "contains",
value,
},
]
: [];
setFilters(filters);
};
return (
<div>
<input
type="search"
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search posts..."
/>
<Table {...tableProps} />
</div>
);
};
Advanced Filter Form
const FilterForm = () => {
const { filters, setFilters } = useTable();
const [status, setStatus] = useState("");
const [category, setCategory] = useState("");
const [dateRange, setDateRange] = useState<[Date, Date] | null>(null);
const handleApplyFilters = () => {
const newFilters: CrudFilters = [];
if (status) {
newFilters.push({
field: "status",
operator: "eq",
value: status,
});
}
if (category) {
newFilters.push({
field: "category.id",
operator: "eq",
value: category,
});
}
if (dateRange) {
newFilters.push({
field: "createdAt",
operator: "between",
value: dateRange,
});
}
setFilters(newFilters);
};
return (
<div>
<select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
{/* Category and date selectors */}
<button onClick={handleApplyFilters}>Apply Filters</button>
<button onClick={() => setFilters([])}>Clear Filters</button>
</div>
);
};
Relational Data
Display data from related resources:import { useMany } from "@refinedev/core";
const PostList = () => {
const { tableQuery: { data: postsData } } = useTable({
resource: "posts",
});
const posts = postsData?.data || [];
// Get unique category IDs
const categoryIds = [...new Set(posts.map((post) => post.categoryId))];
// Fetch all categories at once
const { data: categoriesData } = useMany({
resource: "categories",
ids: categoryIds,
});
const categories = categoriesData?.data || [];
const getCategoryTitle = (categoryId: string) => {
return categories.find((c) => c.id === categoryId)?.title;
};
return (
<table>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td>{post.title}</td>
<td>{getCategoryTitle(post.categoryId)}</td>
</tr>
))}
</tbody>
</table>
);
};
Selection
Row Selection
import { useState } from "react";
const PostList = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const { tableQuery: { data } } = useTable();
const posts = data?.data || [];
const handleSelectAll = () => {
if (selectedRowKeys.length === posts.length) {
setSelectedRowKeys([]);
} else {
setSelectedRowKeys(posts.map((post) => post.id));
}
};
const handleSelectRow = (id: string) => {
if (selectedRowKeys.includes(id)) {
setSelectedRowKeys(selectedRowKeys.filter((key) => key !== id));
} else {
setSelectedRowKeys([...selectedRowKeys, id]);
}
};
return (
<div>
<div>
{selectedRowKeys.length > 0 && (
<button onClick={() => handleBulkDelete(selectedRowKeys)}>
Delete {selectedRowKeys.length} items
</button>
)}
</div>
<table>
<thead>
<tr>
<th>
<input
type="checkbox"
checked={selectedRowKeys.length === posts.length}
onChange={handleSelectAll}
/>
</th>
<th>Title</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td>
<input
type="checkbox"
checked={selectedRowKeys.includes(post.id)}
onChange={() => handleSelectRow(post.id)}
/>
</td>
<td>{post.title}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
Real-Time Updates
Tables automatically update with live data:const { tableQuery } = useTable({
resource: "posts",
liveMode: "auto", // or "manual"
});
// When a post is created/updated/deleted via other users
// The table automatically refetches and updates
URL Sync
Sync table state with URL parameters:const { filters, sorters, current, pageSize } = useTable({
syncWithLocation: true,
});
// URL becomes:
// /posts?current=2&pageSize=20&sorters[0][field]=title&sorters[0][order]=asc&filters[0][field]=status&filters[0][operator]=eq&filters[0][value]=published
// Users can bookmark or share URLs with table state
Export Data
import { useExport } from "@refinedev/core";
const PostList = () => {
const { triggerExport, isLoading } = useExport({
resource: "posts",
mapData: (item) => ({
id: item.id,
title: item.title,
status: item.status,
createdAt: new Date(item.createdAt).toLocaleDateString(),
}),
});
return (
<div>
<button onClick={triggerExport} disabled={isLoading}>
{isLoading ? "Exporting..." : "Export to CSV"}
</button>
<Table {...tableProps} />
</div>
);
};
Advanced Features
Editable Tables
import { useEditableTable } from "@refinedev/antd";
import { Table, Form, Input } from "antd";
const PostList = () => {
const { tableProps, formProps, isEditing, saveButtonProps, cancelButtonProps } = useEditableTable();
return (
<Form {...formProps}>
<Table {...tableProps} rowKey="id">
<Table.Column
dataIndex="title"
title="Title"
render={(value, record) => {
if (isEditing(record.id)) {
return (
<Form.Item name="title" style={{ margin: 0 }}>
<Input />
</Form.Item>
);
}
return value;
}}
/>
<Table.Column
title="Actions"
render={(_, record) => {
if (isEditing(record.id)) {
return (
<>
<button {...saveButtonProps}>Save</button>
<button {...cancelButtonProps}>Cancel</button>
</>
);
}
return (
<button onClick={() => edit(record.id)}>Edit</button>
);
}}
/>
</Table>
</Form>
);
};
Column Visibility
const [visibleColumns, setVisibleColumns] = useState([
"id",
"title",
"status",
]);
const toggleColumn = (columnId: string) => {
if (visibleColumns.includes(columnId)) {
setVisibleColumns(visibleColumns.filter((id) => id !== columnId));
} else {
setVisibleColumns([...visibleColumns, columnId]);
}
};
return (
<div>
<div>
<label>
<input
type="checkbox"
checked={visibleColumns.includes("id")}
onChange={() => toggleColumn("id")}
/>
ID
</label>
{/* More column toggles */}
</div>
<table>
<thead>
<tr>
{visibleColumns.includes("id") && <th>ID</th>}
{visibleColumns.includes("title") && <th>Title</th>}
</tr>
</thead>
</table>
</div>
);
Grouping
const groupedPosts = posts.reduce((acc, post) => {
const status = post.status;
if (!acc[status]) {
acc[status] = [];
}
acc[status].push(post);
return acc;
}, {} as Record<string, Post[]>);
return (
<div>
{Object.entries(groupedPosts).map(([status, posts]) => (
<div key={status}>
<h3>{status} ({posts.length})</h3>
<table>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td>{post.title}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
);
Performance Optimization
Virtualization
For large datasets:import { useVirtualizer } from "@tanstack/react-virtual";
const PostList = () => {
const { tableQuery: { data } } = useTable();
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: data?.data.length || 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const post = data?.data[virtualRow.index];
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{post?.title}
</div>
);
})}
</div>
</div>
);
};
Debounced Filters
import { useDebouncedValue } from "@mantine/hooks";
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 500);
useEffect(() => {
if (debouncedSearch) {
setFilters([{
field: "title",
operator: "contains",
value: debouncedSearch,
}]);
} else {
setFilters([]);
}
}, [debouncedSearch]);
Best Practices
- Use server-side operations - Let the backend handle filtering, sorting, pagination
- Optimize relational queries - Use
useManyto batch fetch related data - Implement loading states - Show skeletons or spinners while loading
- Add empty states - Handle cases when there’s no data
- Enable URL sync - Let users share table states via URL
- Use TypeScript - Type your data for better DX
- Virtualize large lists - Only render visible rows
- Debounce filters - Prevent excessive API calls on search
Next Steps
- Learn about Forms
- Explore Real-time Updates
- Discover Data Providers