Skip to content

Design system

Documentation

DesignPatterns / Data Table

Patterns / Data Table

Draft

Data table

A data table pattern makes search, filters, selection, pagination, and request states explicit without pretending a simple HTML table is a full table product.

On this page

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.

Projects, owners, statuses, and last update times
Atlas onboardingMai NguyenActive8 min ago
Lumen researchLinh DoPaused3 days ago
Nova analyticsQuang LeActive5 days ago
13 of 5
Page 1 of 2

Project data table with staged filters, bulk selection, and pagination

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.
data-table-pattern.tsx
"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

Data table request and result states
StateBoundaryFeedback
loadingKeep table headers and filter context visible.Use a status message and disable duplicate paging/reload actions.
emptyKeep the table structure if it helps orientation.Explain which filter returned no rows and how to clear it.
errorKeep filter choices when they are still safe to retry.Name the failed list and offer Retry.

Accessibility

  • Use a real table with 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

Do

Keep filter, selection, status, and pagination state explicit so the table can be tested one transition at a time.

Don’t

Build a dense grid with divs that only looks like a table or make a bulk action silently affect rows on another page.

  • 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.