Preview
Data table pattern
Stage filters before applying them, keep bulk selection explicit, and give loading, empty, and error states a stable table boundary.
Project directory
Search, filter, and manage project access.
Project data table with staged filters, bulk selection, and pagination
"use client";
import { useMemo, useState } from "react";
import { Button } from "@phuctech/ui/components/button";
import { Checkbox } from "@phuctech/ui/components/checkbox";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@phuctech/ui/components/dialog";
import { Input } from "@phuctech/ui/components/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@phuctech/ui/components/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@phuctech/ui/components/table";
const rows = [{ id: "atlas", name: "Atlas onboarding", owner: "Mai Nguyen", status: "Active" }, { id: "orbit", name: "Orbit billing", owner: "Phuc Tran", status: "Invited" }, { id: "lumen", name: "Lumen research", owner: "Linh Do", status: "Paused" }];
export function DataTablePattern() {
const [draftQuery, setDraftQuery] = useState("");
const [draftStatus, setDraftStatus] = useState("all");
const [query, setQuery] = useState("");
const [status, setStatus] = useState("all");
const [selected, setSelected] = useState<string[]>([]);
const [archiveOpen, setArchiveOpen] = useState(false);
const visibleRows = useMemo(() => rows.filter((row) => (!query || `${row.name} ${row.owner}`.toLowerCase().includes(query.toLowerCase())) && (status === "all" || row.status === status)), [query, status]);
const allSelected = visibleRows.length > 0 && visibleRows.every((row) => selected.includes(row.id));
const someSelected = visibleRows.some((row) => selected.includes(row.id)) && !allSelected;
function archiveSelected() {
setSelected([]);
setArchiveOpen(false);
}
return (
<div className="grid gap-3">
<form className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem_auto]" onSubmit={(event) => { event.preventDefault(); setQuery(draftQuery); setStatus(draftStatus); setSelected([]); }}>
<Input aria-label="Search projects" value={draftQuery} onChange={(event) => setDraftQuery(event.target.value)} placeholder="Search name or owner" />
<Select value={draftStatus} onValueChange={(value) => setDraftStatus(value ?? "all")}><SelectTrigger aria-label="Filter by status"><SelectValue placeholder="All statuses" /></SelectTrigger><SelectContent><SelectItem value="all">All statuses</SelectItem><SelectItem value="Active">Active</SelectItem><SelectItem value="Paused">Paused</SelectItem><SelectItem value="Invited">Invited</SelectItem></SelectContent></Select>
<Button type="submit">Apply filters</Button>
</form>
{selected.length > 0 ? <Dialog open={archiveOpen} onOpenChange={setArchiveOpen}>
<DialogTrigger render={<Button type="button">Archive selected</Button>} />
<DialogContent>
<DialogHeader><DialogTitle>Archive selected projects?</DialogTitle><DialogDescription>Review the visible selection before confirming this bulk action.</DialogDescription></DialogHeader>
<DialogFooter><DialogClose render={<Button type="button" variant="outline">Cancel</Button>} /><Button type="button" variant="destructive" onClick={archiveSelected}>Archive selected</Button></DialogFooter>
</DialogContent>
</Dialog> : null}
<div className="scrollbar-hover-reveal overflow-x-auto rounded-lg border border-border"><Table className="min-w-[40rem]"><TableHeader><TableRow><TableHead><Checkbox aria-label="Select all visible projects" checked={allSelected} indeterminate={someSelected} onCheckedChange={(checked) => setSelected(checked ? visibleRows.map((row) => row.id) : [])} /></TableHead><TableHead>Project</TableHead><TableHead>Owner</TableHead><TableHead>Status</TableHead></TableRow></TableHeader><TableBody>{visibleRows.map((row) => <TableRow key={row.id} selected={selected.includes(row.id)}><TableCell><Checkbox aria-label={`Select ${row.name}`} checked={selected.includes(row.id)} onCheckedChange={(checked) => setSelected((current) => checked ? [...current, row.id] : current.filter((id) => id !== row.id))} /></TableCell><TableCell>{row.name}</TableCell><TableCell>{row.owner}</TableCell><TableCell>{row.status}</TableCell></TableRow>)}</TableBody></Table></div>
</div>
);
}
When to use
Use this pattern when people compare rows, apply filters, and take actions on one or more records. It is a starting composition, not a promise that sorting, column resizing, virtualization, or server-side pagination already exist.
Filters and requests
- Keep draft filter inputs local until the person activates Apply filters; do not request data on every keystroke for a staged form.
- When live search is truly the right interaction, debounce it and cancel or ignore stale requests.
- Fetch a list or a bulk detail endpoint once. Never fire one request per selected row or expanded row.
- Reset the page and selection when a new filter changes the result set.
"use client";
import { useMemo, useState } from "react";
import { Button } from "@phuctech/ui/components/button";
import { Checkbox } from "@phuctech/ui/components/checkbox";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@phuctech/ui/components/dialog";
import { Input } from "@phuctech/ui/components/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@phuctech/ui/components/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@phuctech/ui/components/table";
const rows = [{ id: "atlas", name: "Atlas onboarding", owner: "Mai Nguyen", status: "Active" }, { id: "orbit", name: "Orbit billing", owner: "Phuc Tran", status: "Invited" }, { id: "lumen", name: "Lumen research", owner: "Linh Do", status: "Paused" }];
export function DataTablePattern() {
const [draftQuery, setDraftQuery] = useState("");
const [draftStatus, setDraftStatus] = useState("all");
const [query, setQuery] = useState("");
const [status, setStatus] = useState("all");
const [selected, setSelected] = useState<string[]>([]);
const [archiveOpen, setArchiveOpen] = useState(false);
const visibleRows = useMemo(() => rows.filter((row) => (!query || `${row.name} ${row.owner}`.toLowerCase().includes(query.toLowerCase())) && (status === "all" || row.status === status)), [query, status]);
const allSelected = visibleRows.length > 0 && visibleRows.every((row) => selected.includes(row.id));
const someSelected = visibleRows.some((row) => selected.includes(row.id)) && !allSelected;
function archiveSelected() {
setSelected([]);
setArchiveOpen(false);
}
return (
<div className="grid gap-3">
<form className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem_auto]" onSubmit={(event) => { event.preventDefault(); setQuery(draftQuery); setStatus(draftStatus); setSelected([]); }}>
<Input aria-label="Search projects" value={draftQuery} onChange={(event) => setDraftQuery(event.target.value)} placeholder="Search name or owner" />
<Select value={draftStatus} onValueChange={(value) => setDraftStatus(value ?? "all")}><SelectTrigger aria-label="Filter by status"><SelectValue placeholder="All statuses" /></SelectTrigger><SelectContent><SelectItem value="all">All statuses</SelectItem><SelectItem value="Active">Active</SelectItem><SelectItem value="Paused">Paused</SelectItem><SelectItem value="Invited">Invited</SelectItem></SelectContent></Select>
<Button type="submit">Apply filters</Button>
</form>
{selected.length > 0 ? <Dialog open={archiveOpen} onOpenChange={setArchiveOpen}>
<DialogTrigger render={<Button type="button">Archive selected</Button>} />
<DialogContent>
<DialogHeader><DialogTitle>Archive selected projects?</DialogTitle><DialogDescription>Review the visible selection before confirming this bulk action.</DialogDescription></DialogHeader>
<DialogFooter><DialogClose render={<Button type="button" variant="outline">Cancel</Button>} /><Button type="button" variant="destructive" onClick={archiveSelected}>Archive selected</Button></DialogFooter>
</DialogContent>
</Dialog> : null}
<div className="scrollbar-hover-reveal overflow-x-auto rounded-lg border border-border"><Table className="min-w-[40rem]"><TableHeader><TableRow><TableHead><Checkbox aria-label="Select all visible projects" checked={allSelected} indeterminate={someSelected} onCheckedChange={(checked) => setSelected(checked ? visibleRows.map((row) => row.id) : [])} /></TableHead><TableHead>Project</TableHead><TableHead>Owner</TableHead><TableHead>Status</TableHead></TableRow></TableHeader><TableBody>{visibleRows.map((row) => <TableRow key={row.id} selected={selected.includes(row.id)}><TableCell><Checkbox aria-label={`Select ${row.name}`} checked={selected.includes(row.id)} onCheckedChange={(checked) => setSelected((current) => checked ? [...current, row.id] : current.filter((id) => id !== row.id))} /></TableCell><TableCell>{row.name}</TableCell><TableCell>{row.owner}</TableCell><TableCell>{row.status}</TableCell></TableRow>)}</TableBody></Table></div>
</div>
);
}
Selection
Make the scope of a bulk action visible: show the selected count, keep a select-all checkbox scoped to the visible page, and name the action with its consequence. If the action is destructive, add the shared confirmation step from the CRUD pattern.
States
Accessibility
- Use a real
tablewith a caption, column headers, and row headers when the data is tabular. - Give selection controls names that include the row identity; label the page and filter controls independently.
- Keep pagination buttons keyboard reachable and expose the current page in text, not only through disabled arrows.
- Allow horizontal scrolling for a wide table at small widths; do not shrink labels until they become ambiguous.
Do and don’t
Keep filter, selection, status, and pagination state explicit so the table can be tested one transition at a time.
Build a dense grid with divs that only looks like a table or make a bulk action silently affect rows on another page.
Related
- CRUD defines edit and destructive actions around table rows.
- Badge labels row status with readable text.
- Layout & Grid sets the responsive boundary for wide data surfaces.