Data table
A member list, the kind you often see on admin screens. Filter with search, sort by clicking column headers, and edit or delete each row.
Key points
- 1
Let the library sort, and write the filter yourself
TanStack Table handles sorting when a header is clicked. For search, use filter to decide which members to show first, then pass that list to the table.
- 2
Edit a copy, then swap it in at the end
Keep a copy of the member being edited in state and change it in the dialog. Only when “Save” is pressed, use map to replace that one member in the list. If you cancel, the list doesn’t change.
- 3
Have a screen for “no results”
When no members match the search, don’t show an empty table with only headers — show “No members found”.
Tools used
Tables (sorting)
TanStack Table →
UI components (inputs, buttons, dialogs)
shadcn/ui →
Icons (search, edit, delete)
lucide-react →
Click to see the comparison in Part 2
Edit the code
Edit the code and the preview on the right updates.
import { useState } from "react" import { createColumnHelper, createSortedRowModel, rowSortingFeature, tableFeatures, useTable, } from "@tanstack/react-table" import { PencilIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react" import { Button } from "./components/ui/button" import { Input } from "./components/ui/input" import { Label } from "./components/ui/label" import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "./components/ui/dialog" // The member list (in a real app, this comes from a server) const initialMembers = [ { id: 1, name: "Emily Johnson", email: "emily@example.com", plan: "Paid", joined: "2026-03-14" }, { id: 2, name: "Michael Brown", email: "michael@example.com", plan: "Free", joined: "2026-05-02" }, { id: 3, name: "Sarah Davis", email: "sarah@example.com", plan: "Paid", joined: "2025-11-20" }, { id: 4, name: "David Wilson", email: "david@example.com", plan: "Free", joined: "2026-08-09" }, { id: 5, name: "Olivia Moore", email: "olivia@example.com", plan: "Paid", joined: "2026-01-27" }, { id: 6, name: "James Taylor", email: "james@example.com", plan: "Free", joined: "2026-09-18" }, ] // Features to use (click a column header to sort) const features = tableFeatures({ rowSortingFeature, sortedRowModel: createSortedRowModel(), }) // Column settings: what to show in each column const helper = createColumnHelper<typeof features>() const columns = helper.columns([ helper.accessor("name", { header: "Name" }), helper.accessor("email", { header: "Email" }), helper.accessor("plan", { header: "Plan" }), helper.accessor("joined", { header: "Joined" }), ]) export default function App() { // ① The member list (changes when you add, delete, or edit), kept in state //const members = initialMembers const [members, setMembers] = useState(initialMembers) // ① The search text (starts empty ""), kept in state const [query, setQuery] = useState("") // The member being edited (null when not editing). When adding, an empty member whose id is null const [editing, setEditing] = useState(null) const isNew = editing !== null && editing.id === null // ③ Members to show: only those whose name or email contains the search text (query) const shown = members.filter((member) => member.name.includes(query) || member.email.includes(query)) // const filtered = products.filter((product) => product.name.includes(query) && (category === "All" || product.category === category)) const table = useTable({ features, columns, data: shown }) // Delete the member with this id function removeMember(id) { // ⑤ Keep every member except the one with this id setMembers(members.filter((member) => member.id !== id)) } // Open the add dialog (makes an empty member; the join date is today, treated as 10/4) function startAdd() { setEditing({ id: null, name: "", email: "", plan: "Free", joined: "2026-10-04" }) } // Save the dialog (add to the end if new, otherwise replace just that one member) function saveMember() { if (isNew) { const newId = Math.max(0, ...members.map((member) => member.id)) + 1 // The largest existing id + 1 // ⑧ Add the new member (editing, with id set to newId) to the end of members setMembers([...members, { ...editing, id: newId }]) } else { // ⑦ Replace only the member whose id matches editing with editing setMembers(members.map((member) => (member.id === editing.id ? { ...editing } : member))) } setEditing(null) } return ( <div className="mx-auto my-6 flex w-full max-w-4xl flex-col gap-4 px-4 text-foreground"> <div className="flex flex-wrap items-center justify-between gap-3"> <h1 className="text-xl font-semibold"> Members <span className="text-sm font-normal text-muted-foreground">({shown.length})</span> </h1> <div className="flex items-center gap-2"> <div className="relative w-56"> <SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" /> {/* ② Put the typed text into query (value and onChange) */} <Input value={query} onChange={(e) => setQuery(e.target.value)} className="pl-8" placeholder="Search by name or email" /> </div> <Button onClick={startAdd}> <PlusIcon /> Add member </Button> </div> </div> <div className="overflow-hidden rounded-xl border bg-background"> <table className="w-full text-sm"> <thead> {table.getHeaderGroups().map((group) => ( <tr key={group.id} className="bg-muted/50"> {group.headers.map((header) => ( <th key={header.id} onClick={header.column.getToggleSortingHandler()} className="cursor-pointer px-4 py-2.5 text-left font-medium text-muted-foreground select-none hover:text-foreground" > <table.FlexRender header={header} /> {header.column.getIsSorted() === "asc" && " ▲"} {header.column.getIsSorted() === "desc" && " ▼"} </th> ))} <th className="px-4 py-2.5 text-right font-medium text-muted-foreground">Actions</th> </tr> ))} </thead> <tbody> {table.getRowModel().rows.map((row) => ( <tr key={row.id} className="hover:bg-muted/30"> {row.getAllCells().map((cell) => ( <td key={cell.id} className="border-t px-4 py-2.5"> <table.FlexRender cell={cell} /> </td> ))} <td className="border-t px-4 py-1.5 text-right whitespace-nowrap"> <Button variant="ghost" size="icon-sm" aria-label="Edit" className="text-muted-foreground" onClick={() => setEditing(row.original)}> <PencilIcon /> </Button> <Button variant="ghost" size="icon-sm" aria-label="Delete" className="text-muted-foreground hover:bg-red-50 hover:text-red-600" onClick={() => removeMember(row.original.id)}> <Trash2Icon /> </Button> </td> </tr> ))} </tbody> </table> {/* ④ Show the text below only when there are no members to show */} {shown.length === 0 && ( <p className="border-t py-10 text-center text-sm text-muted-foreground">No matching members</p> )} </div> <p className="text-xs text-muted-foreground">Click a column header to sort</p> {/* Add/edit dialog (open only while editing holds a member) */} <Dialog open={editing !== null} onOpenChange={(isOpen) => !isOpen && setEditing(null)}> <DialogContent className="p-6"> <DialogHeader> <DialogTitle className="text-lg font-semibold">{isNew ? "Add member" : "Edit member"}</DialogTitle> </DialogHeader> {editing && ( <div className="flex flex-col gap-4"> <div className="flex flex-col gap-2"> <Label htmlFor="name">Name</Label> {/* ⑥ Change only editing's name to the typed text (onChange) */} <Input id="name" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} /> </div> <div className="flex flex-col gap-2"> <Label htmlFor="email">Email</Label> {/* ⑥ Change only editing's email to the typed text (onChange) */} <Input id="email" value={editing.email} onChange={(e) => setEditing({ ...editing, email: e.target.value })} /> </div> </div> )} <DialogFooter className="mx-0 mb-0 border-t-0 bg-transparent p-0 pt-2"> <DialogClose asChild> <Button variant="outline">Cancel</Button> </DialogClose> <Button disabled={editing === null || editing.name === "" || editing.email === ""} onClick={saveMember}> {isNew ? "Add" : "Save"} </Button> </DialogFooter> </DialogContent> </Dialog> </div> ) }