Skip to main content
Refine provides powerful table hooks that handle data fetching, pagination, sorting, filtering, and real-time updates automatically. Works with any table library or build your own.

Core Table Hook

The useTable 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

1
Choose Your Approach
2
Pick a table library or build headless:
3
  • TanStack Table (recommended for headless)
  • Ant Design Table
  • Material UI DataGrid
  • Mantine Table
  • Custom HTML tables
  • 4
    Install Dependencies
    5
    # 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
    
    6
    Create a Basic List
    7
    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

    1. Use server-side operations - Let the backend handle filtering, sorting, pagination
    2. Optimize relational queries - Use useMany to batch fetch related data
    3. Implement loading states - Show skeletons or spinners while loading
    4. Add empty states - Handle cases when there’s no data
    5. Enable URL sync - Let users share table states via URL
    6. Use TypeScript - Type your data for better DX
    7. Virtualize large lists - Only render visible rows
    8. Debounce filters - Prevent excessive API calls on search

    Next Steps

    Build docs developers (and LLMs) love