데이터 테이블
관리 화면에서 흔히 보는 회원 목록입니다. 검색으로 좁히고, 열 제목을 눌러 정렬하고, 행마다 편집하거나 삭제할 수 있습니다.
회원 목록
핵심 포인트
- 1
정렬은 도구에 맡기고, 좁히기는 직접 쓰기
제목을 눌렀을 때의 정렬은 TanStack Table이 맡습니다. 검색은 filter로 먼저 "보여 줄 회원"을 정하고, 그 목록을 테이블에 넘깁니다.
- 2
편집은 "복사본을 고치고 마지막에 바꿔 넣기"
편집 중인 회원의 복사본을 state에 넣고 대화 상자에서 고칩니다. "저장"을 눌렀을 때만 map으로 목록의 그 회원을 바꿔 넣습니다. "취소"를 누르면 목록은 바뀌지 않습니다.
- 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> ) }