데이터 테이블

관리 화면에서 흔히 보는 회원 목록입니다. 검색으로 좁히고, 열 제목을 눌러 정렬하고, 행마다 편집하거나 삭제할 수 있습니다.

회원 목록

핵심 포인트

  1. 1

    정렬은 도구에 맡기고, 좁히기는 직접 쓰기

    제목을 눌렀을 때의 정렬은 TanStack Table이 맡습니다. 검색은 filter로 먼저 "보여 줄 회원"을 정하고, 그 목록을 테이블에 넘깁니다.

  2. 2

    편집은 "복사본을 고치고 마지막에 바꿔 넣기"

    편집 중인 회원의 복사본을 state에 넣고 대화 상자에서 고칩니다. "저장"을 눌렀을 때만 map으로 목록의 그 회원을 바꿔 넣습니다. "취소"를 누르면 목록은 바뀌지 않습니다.

  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: "minjun@example.com", plan: "유료", joined: "2026-03-14" },
  { id: 2, name: "이서연", email: "seoyeon@example.com", plan: "무료", joined: "2026-05-02" },
  { id: 3, name: "박지훈", email: "jihoon@example.com", plan: "유료", joined: "2025-11-20" },
  { id: 4, name: "최수아", email: "sua@example.com", plan: "무료", joined: "2026-08-09" },
  { id: 5, name: "정하준", email: "hajun@example.com", plan: "유료", joined: "2026-01-27" },
  { id: 6, name: "강지민", email: "jimin@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" })
  }

  // 대화상자 내용을 저장한다(새 회원이면 목록 끝에 추가, 편집이면 그 한 명만 바꾼다)
  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으로 바꾼다
      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>
  )
}