Full example
One orders table with every feature on, and all of its code.
Try it: search da nang, filter by Pickup between two times, Created at between two moments or City is any of two cities, sort by several columns with Shift, then select a few rows and Mark as shipped or Delete them. Double-click a customer, status, city, shipped or amount to edit it: the city searches a fake API, and a negative amount is refused. Reload: the search, filter, sort and page come back from the URL, and the column layout from your browser.
What's in it
- Search in the order id, customer, email and city, ignoring accents.
- A filter for each field type: text, select, multi-select, number, yes/no, date, time of day, and date with time.
- Sorting, newest orders first by default, and pages of 10 to 100 rows.
- Row selection with bulk actions. Refunded orders can't be selected.
- Inline editing of the customer, status, city, shipped and amount, saved after a fake 400 ms request that refuses negative amounts. The city's options come from a search in a fake API.
- The order id pinned to the start, the amount to the end, and the email hidden until turned on in Columns.
- Columns users can move, resize and color, saved in
localStorage. - A reload button with the loading state.
The code
Install the blocks first, as in Installation. The files below then work as they are.
Fields and data
The fields decide what the filter offers. The rows are made up here; yours come from your API or database.
import type { FieldDefinition } from "@querycn/filter-core"
export interface Order {
id: string
customer: string
email: string
status: "paid" | "pending" | "refunded"
city: string
amount: number
shipped: boolean
deliveryDate: string
pickup: string
createdAt: string
}
const CITIES = ["Hà Nội", "Hồ Chí Minh", "Đà Nẵng", "Singapore", "Bangkok"]
export const cityOptions = CITIES.map((city) => ({ label: city, value: city }))
// One field per filter type: text, select, multiSelect, number, boolean, date, time, datetime.
export const orderFields: FieldDefinition[] = [
{ name: "customer", label: "Customer", type: "text" },
{
name: "status",
label: "Status",
type: "select",
options: [
{ label: "Paid", value: "paid" },
{ label: "Pending", value: "pending" },
{ label: "Refunded", value: "refunded" },
],
},
{
name: "city",
label: "City",
type: "multiSelect",
options: cityOptions,
},
{ name: "amount", label: "Amount", type: "number", defaultOperator: "gte" },
{ name: "shipped", label: "Shipped", type: "boolean" },
{ name: "deliveryDate", label: "Delivery", type: "date" },
{ name: "pickup", label: "Pickup", type: "time" },
{ name: "createdAt", label: "Created at", type: "datetime" },
]
const FIRST =
"Nguyễn|Olivia|Trần|Jackson|Đặng|Isabella|Lê|Noah|Phạm|Emma".split("|")
const LAST =
"Văn An|Martin|Thị Bình|Lee|Minh Châu|Nguyen|Hoàng|Smith|Quốc Huy|Brown".split(
"|"
)
const STATUSES = ["paid", "pending", "paid", "refunded", "paid"] as const
const pad = (part: number) => String(part).padStart(2, "0")
const day = (offset: number) =>
new Date(Date.UTC(2026, 0, 1 + offset)).toISOString().slice(0, 10)
// Made-up but stable, so the server and the browser render the same rows.
// Times have no zone, so every visitor sees and filters the same hours.
export const ORDERS: Order[] = Array.from({ length: 500 }, (_, index) => {
const created = (index * 37) % 270
const status = STATUSES[(index * 3) % 5]!
return {
id: `ORD-${1001 + index}`,
customer: `${FIRST[index % 10]} ${LAST[(index * 7) % 10]}`,
email: `customer${1001 + index}@example.com`,
status,
city: CITIES[(index * 11) % 5]!,
amount: ((index * 7919) % 99000) / 100 + 5,
shipped: status === "paid" && index % 3 !== 0,
deliveryDate: day(created + 2 + (index % 5)),
pickup: `${pad(8 + ((index * 5) % 13))}:${pad(((index * 7) % 4) * 15)}`,
createdAt: `${day(created)}T${pad((index * 5) % 24)}:${pad((index * 17) % 60)}`,
}
})
The table
"use client"
import * as React from "react"
import { filterOptions, parseDateOnly } from "@querycn/filter-core"
import { NextFilterProvider } from "@querycn/filter-next"
import {
createDataTableColumnHelper,
resetPagePatch,
useDataTable,
} from "@querycn/table-react"
import { CheckIcon, Trash2Icon, TruckIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { DataTable } from "@/components/data-table/data-table"
import { DataTablePagination } from "@/components/data-table/data-table-pagination"
import { DataTableSearch } from "@/components/data-table/data-table-search"
import { createSelectionColumn } from "@/components/data-table/data-table-selection-column"
import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
import { FilterBuilder } from "@/components/filter/filter-builder"
import { FilterChips } from "@/components/filter/filter-chips"
import { cityOptions, ORDERS, orderFields, type Order } from "./orders-data"
const statusVariant = {
paid: "default",
pending: "secondary",
refunded: "outline",
} as const
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
// A fixed locale, so the server and the browser print the same text.
const dayFormat = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
// `parseDateOnly` reads the day in local time; `new Date("2026-03-05")` would be UTC.
const formatDay = (day: string) => {
const date = parseDateOnly(day)
return date ? dayFormat.format(date) : day
}
const helper = createDataTableColumnHelper<Order>()
// Stands in for your API: the cities matching what the user types.
async function loadCities(search: string, signal: AbortSignal) {
await new Promise((resolve) => setTimeout(resolve, 300))
signal.throwIfAborted()
return [...filterOptions(cityOptions, search)]
}
const statusOptions = [
{ label: "Paid", value: "paid" },
{ label: "Pending", value: "pending" },
{ label: "Refunded", value: "refunded" },
]
const columns = [
createSelectionColumn<Order>(),
helper.accessor("id", {
header: "Order",
size: 110,
meta: { defaultPinned: "start" },
}),
helper.accessor("customer", {
header: "Customer",
size: 180,
meta: { edit: { type: "text" } },
}),
helper.accessor("email", {
header: "Email",
size: 230,
meta: { defaultHidden: true },
}),
helper.accessor("status", {
header: "Status",
size: 110,
meta: { edit: { type: "select", options: statusOptions } },
cell: ({ getValue }) => {
const status = getValue<Order["status"]>()
return (
<Badge variant={statusVariant[status]} className="capitalize">
{status}
</Badge>
)
},
}),
helper.accessor("city", {
header: "City",
size: 140,
meta: { edit: { type: "select", loadOptions: loadCities } },
}),
helper.accessor("shipped", {
header: "Shipped",
size: 100,
meta: { edit: { type: "boolean" } },
cell: ({ getValue }) =>
getValue<boolean>() ? (
<CheckIcon aria-label="Shipped" className="size-4" />
) : (
<span className="text-muted-foreground">No</span>
),
}),
helper.accessor("deliveryDate", {
header: "Delivery",
size: 120,
cell: ({ getValue }) => (
<span className="tabular-nums">{formatDay(getValue<string>())}</span>
),
}),
helper.accessor("pickup", {
header: "Pickup",
size: 100,
cell: ({ getValue }) => (
<span className="tabular-nums">{getValue<string>()}</span>
),
}),
helper.accessor("createdAt", {
header: "Created at",
size: 160,
cell: ({ getValue }) => {
const [day, time] = getValue<string>().split("T")
return (
<span className="tabular-nums">
{formatDay(day!)} {time}
</span>
)
},
}),
helper.accessor("amount", {
header: "Amount",
size: 120,
sortDescFirst: true,
meta: { defaultPinned: "end", edit: { type: "number" } },
cell: ({ getValue }) => (
<span className="tabular-nums">{money.format(getValue<number>())}</span>
),
}),
]
const searchColumns = ["id", "customer", "email", "city"]
function OrdersTable() {
const [orders, setOrders] = React.useState(ORDERS)
const [isRefreshing, setIsRefreshing] = React.useState(false)
const table = useDataTable({
data: orders,
columns,
getRowId: (order) => order.id,
storageKey: "tablecn-full-example",
searchColumns,
url: {
pageSizes: [10, 20, 50, 100],
defaultSorting: [{ id: "createdAt", desc: true }],
},
enableRowSelection: (row) => row.original.status !== "refunded",
// Stands in for saving to your API: throw to keep the editor open with a message.
onCellEdit: async ({ rowId, columnId, value }) => {
await new Promise((resolve) => setTimeout(resolve, 400))
if (columnId === "amount" && (value === null || (value as number) < 0)) {
throw new Error("Enter an amount of 0 or more.")
}
setOrders((current) =>
current.map((order) =>
order.id === rowId ? { ...order, [columnId]: value } : order
)
)
},
})
const update = (ids: string[], change: (order: Order) => Order | null) => {
const selected = new Set(ids)
setOrders((current) =>
current.flatMap((order) => {
if (!selected.has(order.id)) return [order]
const next = change(order)
return next ? [next] : []
})
)
table.resetRowSelection(true)
}
return (
<>
<DataTableToolbar
table={table}
// Stands in for refetching from your API.
onRefresh={() => {
setIsRefreshing(true)
setTimeout(() => setIsRefreshing(false), 800)
}}
isRefreshing={isRefreshing}
selectionActions={(rows) => {
const ids = rows.map((row) => row.id)
return (
<>
<Button
variant="outline"
size="sm"
onClick={() =>
update(ids, (order) => ({ ...order, shipped: true }))
}
>
<TruckIcon />
Mark as shipped
</Button>
<Button
variant="outline"
size="sm"
onClick={() => update(ids, () => null)}
>
<Trash2Icon />
Delete
</Button>
</>
)
}}
>
<DataTableSearch table={table} placeholder="Search orders…" />
<FilterBuilder />
<FilterChips />
</DataTableToolbar>
<DataTable
table={table}
isLoading={isRefreshing}
className="max-h-[480px]"
/>
<DataTablePagination table={table} />
</>
)
}
/** Search, filter, sort and page in the URL; needs a Suspense boundary above it. */
export function OrdersPage() {
return (
<NextFilterProvider
fields={orderFields}
shallow
onApply={() => resetPagePatch()}
>
<OrdersTable />
</NextFilterProvider>
)
}
The page
NextFilterProvider reads the URL with useSearchParams, so the page needs a Suspense boundary:
import { Suspense } from "react"
import { OrdersPage } from "@/components/orders-table"
export default function Page() {
return (
<Suspense>
<OrdersPage />
</Suspense>
)
}The filtering, sorting and paging here run in the browser, over every row. For a large table, let your backend do it: see Server data.