一覧表

管理画面によくある、会員の一覧表です。検索で絞り込み、列の見出しで並び替え、行ごとに編集・削除ができます。

会員一覧

ここがポイント

  1. 1

    並び替えは道具にまかせ、絞り込みは自分で書く

    見出しを押したときの並び替えは TanStack Table が受け持つ。検索は filter で「出す会員」を先に決めて、その一覧を表に渡す

  2. 2

    編集は「コピーを直して、最後に入れ替える」

    編集中の会員のコピーを state に持ち、ダイアログで直す。「保存する」を押したときだけ、map で一覧の中の1人を入れ替える。「やめる」なら一覧は変わらない

  3. 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>
  )
}