一覧表
管理画面によくある、会員の一覧表です。検索で絞り込み、列の見出しで並び替え、行ごとに編集・削除ができます。
会員一覧
ここがポイント
- 1
並び替えは道具にまかせ、絞り込みは自分で書く
見出しを押したときの並び替えは TanStack Table が受け持つ。検索は filter で「出す会員」を先に決めて、その一覧を表に渡す
- 2
編集は「コピーを直して、最後に入れ替える」
編集中の会員のコピーを state に持ち、ダイアログで直す。「保存する」を押したときだけ、map で一覧の中の1人を入れ替える。「やめる」なら一覧は変わらない
- 3
0件のときの表示を用意する
検索に合う会員がいないとき、見出しだけの空の表にせず「該当する会員がいません」と出す
使った道具
押すと、第2部のくらべるページへ
コードを書き換えてみる
コードを書き換えると、右の見た目が変わります
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" // 会員の一覧(本物では、サーバーから取ってくる) const initialMembers = [ { id: 1, name: "佐藤 花子", email: "hanako@example.com", plan: "有料", joined: "2026-03-14" }, { id: 2, name: "鈴木 一郎", email: "ichiro@example.com", plan: "無料", joined: "2026-05-02" }, { id: 3, name: "高橋 美咲", email: "misaki@example.com", plan: "有料", joined: "2025-11-20" }, { id: 4, name: "田中 健", email: "ken@example.com", plan: "無料", joined: "2026-08-09" }, { id: 5, name: "伊藤 さくら", email: "sakura@example.com", plan: "有料", joined: "2026-01-27" }, { id: 6, name: "渡辺 翔", email: "sho@example.com", plan: "無料", joined: "2026-09-18" }, ] // 使う機能(列の見出しを押すと並び替え) const features = tableFeatures({ rowSortingFeature, sortedRowModel: createSortedRowModel(), }) // 列の設定:どの列に、何を出すか const helper = createColumnHelper<typeof features>() const columns = helper.columns([ helper.accessor("name", { header: "名前" }), helper.accessor("email", { header: "メール" }), helper.accessor("plan", { header: "プラン" }), helper.accessor("joined", { header: "登録日" }), ]) export default function App() { // ① 会員の一覧(追加・削除・編集で変わる)。今は決まった値。ここを state にする //const members = initialMembers const [members, setMembers] = useState(initialMembers) // ① 検索の文字(最初は空っぽ "")。今は決まった値。ここを state にする const [query, setQuery] = useState("") // 編集中の会員(編集していないときは null)。新規追加のときは、id が null の空っぽの会員 const [editing, setEditing] = useState(null) const isNew = editing !== null && editing.id === null // ③ 表に出す会員。今は全員。ここに:検索の文字(query)が、名前かメールに入っている会員だけにする const shown = members.filter((member) => member.name.includes(query) || member.email.includes(query)) // const filtered = products.filter((product) => product.name.includes(query) && (category === "すべて" || product.category === category)) const table = useTable({ features, columns, data: shown }) // この id の会員を消す function removeMember(id) { // ⑤ ここに:members から、この id の会員を消す setMembers(members.filter((member) => member.id !== id)) } // 新規追加のダイアログを開く(空っぽの会員を作る。登録日は今日=10/4 として作っている) function startAdd() { setEditing({ id: null, name: "", email: "", plan: "無料", joined: "2026-10-04" }) } // ダイアログの内容を保存する(新規なら一覧の最後に足す、編集なら1人だけ入れ替える) function saveMember() { if (isNew) { const newId = Math.max(0, ...members.map((member) => member.id)) + 1 // 今ある一番大きい id + 1 // ⑧ ここに:members の最後に、新しい会員(editing の中身に、id だけ newId を入れたもの)を足す setMembers([...members, { ...editing, id: newId }]) } else { // ⑦ ここに:members の中の、editing と同じ id の会員だけを、editing s 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"> 会員一覧 <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" /> {/* ② ここに:打った文字を query に入れる(value と onChange) */} <Input value={query} onChange={(e) => setQuery(e.target.value)} className="pl-8" placeholder="名前・メールで検索" /> </div> <Button onClick={startAdd}> <PlusIcon /> 新規追加 </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">操作</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="編集" className="text-muted-foreground" onClick={() => setEditing(row.original)}> <PencilIcon /> </Button> <Button variant="ghost" size="icon-sm" aria-label="削除" className="text-muted-foreground hover:bg-red-50 hover:text-red-600" onClick={() => removeMember(row.original.id)}> <Trash2Icon /> </Button> </td> </tr> ))} </tbody> </table> {/* ④ ここに:表に出す会員が0人のときだけ、下の文を出す */} {shown.length === 0 && ( <p className="border-t py-10 text-center text-sm text-muted-foreground">該当する会員がいません</p> )} </div> <p className="text-xs text-muted-foreground">列の見出しを押すと、並び替えができます</p> {/* 追加・編集のダイアログ(editing に会員が入っているときだけ開く) */} <Dialog open={editing !== null} onOpenChange={(isOpen) => !isOpen && setEditing(null)}> <DialogContent className="p-6"> <DialogHeader> <DialogTitle className="text-lg font-semibold">{isNew ? "会員を追加" : "会員を編集"}</DialogTitle> </DialogHeader> {editing && ( <div className="flex flex-col gap-4"> <div className="flex flex-col gap-2"> <Label htmlFor="name">名前</Label> {/* ⑥ ここに:打った文字で、editing の name だけを変える(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">メール</Label> {/* ⑥ ここに:打った文字で、editing の email だけを変える(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">やめる</Button> </DialogClose> <Button disabled={editing === null || editing.name === "" || editing.email === ""} onClick={saveMember}> {isNew ? "追加する" : "保存する"} </Button> </DialogFooter> </DialogContent> </Dialog> </div> ) }