Profile Image
블로그를 옮겼어요.
재사용 가능하게, Tanstack-table 사용하기
개발
2024.06.01.

img

올해 초 이직을 하면서 주된 프로덕트가 어드민이다.
어드민에서 테이블, 폼과 같은 구조를 많이 다루게 된다.
처음 맡은 프로젝트에서 Tanstack-table을 사용했는데, 점점 사람들에게 알려지기도 하고,
메인테이너가 docs도 깔끔하게 정리 중이라 더 관심이 간다.
(초반 docs는 예시밖에 없었다;)

기본 바탕이 되는 내용은 이전 글에서 다뤘으니, 이번엔 재사용 가능하게 Tanstack-table을 구성해보려고 한다.
shadcn/ui의 Data Table docs를 참고했으며, 따라해보면서 겪었던 문제를 공유해본다.


재사용가능한 구조로 나누기

- └── table
-     └── index.tsx


+ └── table
+     ├── columns.tsx
+     ├── pagination.tsx
+     ├── selection.tsx
+     └── data-table.tsx

기존에는 모든 소스코드가 table 내 index.tsx에 포함되어있었다.
table/index.tsx내 columns도, pagination, selection 모든게 포함되어있다.
즉, 한 번 사용할 순 있지만, 재사용하긴 어려운 구조다.

폴더구조를 변경시킴으로써, 해당 부분에서 columns만 모아놓고 필요한 것만 빼내서 사용할 수 있다.


1. columns.tsx

// table/columns.tsx
-  const columnHelper = createColumnHelper<ColumnDataProps>();
-  const columns = [
-    //...
-  ];

기존에 columns를 만들 때 createColumnHelper를 사용했었고,
이전 글에도 createColumnHelper 더 권장했다.

하지만, 이번에 적용해보면서, 타입설정해주기가 너무 까다롭다는 걸 알게됐다.
data-table의 제네릭으로 내려주는 게 있는데, columns에서 타입에러를 뱉어냈다.
고민하다가, 다음과 같은 방법으로 바꾸었다.

// table/columns.tsx
import { type ColumnDef } from '@tanstack/react-table';

type Status = {
  id: number;
  name: string;
};
type ColumnDataProps = {
  task: string;
  status: Status;
  due?: Date | null;
  notes: string;
  done: boolean;
};

export const columns: ColumnDef<ColumnDataProps>[] = [
  {
    accessorKey: 'done',
    header: ({ table }) => (
      <Checkbox
        checked={
          table.getIsAllPageRowsSelected() ||
          (table.getIsSomePageRowsSelected() && 'indeterminate')
        }
        onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
        aria-label="Select all"
      />
    ),
    cell: ({ row }) => (
      <Checkbox
        checked={row.getIsSelected()}
        onCheckedChange={(value) => row.toggleSelected(!!value)}
        aria-label="Select row"
      />
    ),
    size: 50,
  },
  {
    accessorKey: 'task',
    header: ({ column }) => (
      <div
        className="flex cursor-pointer items-center justify-center"
        onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
      >
        Task
        <ArrowUpDown className="ml-2 size-4" />
      </div>
    ),
    cell: ({ row }) => {
      return <div>{row.getValue('task')}</div>;
    },
  },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => <p>{row.getValue('status')}</p>,
    enableSorting: false,
  },
  {
    accessorKey: 'due',
    header: 'Due',
    cell: ({ row }) => <p>{row.getValue('due')}</p>,
    enableSorting: false,
  },
  {
    accessorKey: 'notes',
    header: 'Notes',
    cell: ({ row }) => <p>{row.getValue('notes')}</p>,
    enableSorting: false,
  },
];

ColumnDef를 가져온 뒤, columns타입으로 지정해준다.
이는 column의 header나, cell을 지정해줄 때도 잘 추론해준다.

type inference


🤔 columns의 타입에러

columns를 분리하고 난 뒤, 다음과 같은 타입에러가 뜬다. columns type error

  export type ColumnDataProps = {
    task: string;
    status: Status;
    due?: Date | null;
    notes: string;
+   done: boolean;
  };

타입에는 done 프로퍼티 추가해줬는데, mocking 데이터에 done이 반영되지 않았기 때문이었다.
여기서 done은 행(row)의 체크상태를 관리하기 위한 데이터이다.

// data.js
const generateRandomData = () => {
  const data = [];
  for (let i = 0; i < 100; i++) {
    data.push({
      //...
      done: false, // 해당 부분 추가
    });
  }
  return data;
};

const DATA = generateRandomData();
export default DATA

🤔 columns내 cell의 format 에러

이 상태로 페이지를 띄워보자.
화면이 흰색이라, 개발자도구를 확인해봤다.

runtime error

columns 내에서 에러가 발생한 것 같다.
대략 예상으론, cell의 <p>태그 내, value가 잘 주입되어야하는데, 문제가 발생한 것 같다.

확인해보니, Date format 설정이 되어있지 않았다.
date-fns 라이브러리를 통해, format을 지정해주었다.

import { format } from 'date-fns';

export const columns: ColumnDef<ColumnDataProps>[] = [
  //...
  {
    accessorKey: 'due',
    header: 'Due',
    cell: ({ row }) => <p>{format(row.getValue('due'), 'yyyy/MM/dd')}</p>, //
    enableSorting: false,
  },
];

🤔 columns의 Deep Keys

Status 타입을 살펴보면, id와 name이 존재한다.
그리고 ColumnDateProps의 status 프로퍼티에 Status 타입을 지정해주었다.
즉, value를 가져오기 위해선 row.getValue("status").name으로 설정해줘야할 것 같지만,
타입에러가 발생한다.

status type error

어떻게 status 내부에 있는 name 프로퍼티를 가져올 수 있을까?

공식문서 Column Defs의 Deep Keys를 살펴보면, 다음과 같이 적용할 수 있다.
(이 부분은 개인적으로 정말 신기했다.)

type Status = {
  id: number;
  name: string;
};

type ColumnDataProps = {
  //...
  status: Status;
};

  export const columns: ColumnDef<ColumnDataProps>[] = [
    //...
    {
-     accessorKey: 'status',
+     accessorKey: 'status.name',
+     id: 'name',
      header: 'Status',
-     cell: ({ row }) => <p>{row.getValue('status')}</p>,
+     cell: ({ row }) => <p>{row.getValue('name')}</p>,
      enableSorting: false,
    },
 ];

accessorKey는 객체일 경우 다음과 같이 사용하면 된다. → status.name 또는 status.id
그리고 꼭 id를 추가해준다. → id: name
마지막으로, cell에서 row.getValue를 name으로 변경한다. → row.getValue("name")
이제 Status의 name을 가져올 수 있다.

만약 id를 가져오고 싶다면,
accessorKey → status.id로 변경해주면 된다.
단, row.getValue()는 데이터를 가져올 때, id를 참조한다.
그래서 idrow.getValue(id)는 동일해야한다.

2. pagination

// table/pagination.tsx
import { Button } from '@/components/ui/button';

export const Pagination = ({ table }) => {
  return (
    <div className="mt-[10px] flex items-center justify-center gap-2">
      <Button
        variant="outline"
        size="sm"
        onClick={() => table.previousPage()}
        disabled={!table.getCanPreviousPage()}
      >
        {'‹'}
      </Button>

      <div className="text-sm font-bold text-slate-500">
        Page {table.getState().pagination.pageIndex + 1} of{' '}
        {table.getPageCount()}
      </div>

      <Button
        variant="outline"
        size="sm"
        disabled={!table.getCanNextPage()}
        onClick={() => table.nextPage()}
      >
        {'›'}
      </Button>
    </div>
  );
};

table/index.tsx 중 pagination에 해당하는 소스코드는 다음과 같다.
여기서 타입지정이 중요한데, 제네릭으로 설정해주면 편하다.

type PaginationProps<TData> = {
  table: Table<TData>;
};

export const Pagination = <TData,>({ table }: PaginationProps<TData>) => {
  return (
    //...
  );
};

글을 시작할 초반에, 메인테이너가 docs를 깔끔하게 정리중이라고 언급했었다.
공식문서 중, Data Guide를 살펴보면,
제네릭 타입에 관한 잘 정리된 글을 확인할 수 있다.

문서에서 TData라는게 눈에 띄는데,
내가 만든 예시에선 Columns.tsx에 존재하는, ColumnDataProps가 TData로 받게 될 것이다.

type ColumnDataProps = {
  task: string;
  status: Status;
  due?: Date | null;
  notes: string;
  done: boolean;
};

이제 정리해보면, 다음과 같다.

// table/index.tsx
import { Pagination } from '@/components/table/pagination';


export const TableComponents: React.FC = () => {
  const table = useReactTable({
   //...
  });

  return (
    <>
      //...

      <Pagination table={table} /> // 컴포넌트 내 table 인스턴스만 주입시키면 된다.
    </>
  );
};

3. data-table

이제 TableComponents를 변경시켜보자.
테이블 전체를 재사용하기 위해선, 크게 columns과 data만 내려받으면 된다.
나머지는 useReactTable hooks을 통해 리턴받은 table 인스턴스로 처리할 수 있다.

// App.tsx
import { useState } from 'react';
import DATA from '@/data';
import { columns } from '@/components/table/columns';
import { TableComponents } from '@/components/table';
import { TableCaption } from '@/components/ui/table';

function App() {
  const [data] = useState(DATA);

  return (
    <div className="h-screen w-screen">
      <div className="mx-auto w-[900px] pb-20 pt-10">
        <TableCaption className="mb-10 text-3xl font-bold">
          Tanstack Table
        </TableCaption>
        <TableComponents data={data} columns={columns} />
      </div>
    </div>
  );
}

export default App;

TableComponents 내부에 존재하던, data와 columns를 부모컴포넌트로 옮겨주었다.
그리고 data와 columns를 props로 내려준다.

import { type ColumnDef } from '@tanstack/react-table';


type TableProps<TData, TValue> = {
  data: TData[];
  columns: ColumnDef<TData, TValue>[];
};

export const TableComponents = <TData, TValue>({
  data,
  columns,
}: TableProps<TData, TValue>) => {
  const table = useReactTable({
    data,
    columns,
    //...
  });

  return (
    <Selection table={table}>
      <Table style={{ width: '100%', borderCollapse: 'collapse' }}>
      //...
      </Table>
      <Pagination table={table} />
    </Selection>
  );
};

주목해야할 부분은 역시 타입인 것 같다. TData는 위에서 언급했지만, TValue는 어떤 것일까?

type ColumnDataProps = {
  task: string;
  status: Status;
  due?: Date | null;
  notes: string;
  done: boolean;
};

여기서 TValue는 type의 value(string, Status, Date)이다.
하지만 한 가지 의문이 드는게 있다.

import { type ColumnDef } from '@tanstack/react-table';


type TableProps<TData, TValue> = {
  data: TData[];
  columns: ColumnDef<TData, TValue>[];
};

export const TableComponents = <TData, TValue>({
  data,
  columns, // check type
}: TableProps<TData, TValue>) => {
  const table = useReactTable({
    data,
    columns, // check type
    //...
  });

  return (
  // ...
  );
};

매개변수로 받은 columns의 타입은 columns: ColumnDef<TData, TValue>[]이어야한다.
그리고 useReactTable 내에 주입되는 columns 역시 동일한 타입일 것이라고 예상했다.
확인해보자

parameter column

useReactTable column

왜 useReactTable 내 columns는 ColumnDef<TData, any>[]인걸까..?
한참을 고민하면서 검색해봤는데, 내부 소스코드를 확인 후 바로 원인을 파악할 수 있었다.
이는 useReactTable hooks 내부에서 columns의 타입이 columnDef<TData, any>[]타입이었기 때문이었다. 😭


마치며

이번 글을 작성하며 느낀 점을 간략히 적어보자면,

tanstack-table은 재사용하기 쉽도록 만들어져있다.
스타일을 적용해주고, 파일을 나눠준 뒤, 타입을 반영시켜주면,
유연하게 사용할 수 있는 테이블 컴포넌트를 만들 수 있다.

다음 글로 tanstack-table의 server side pagination을 작성 중인데,
이 역시 서버에서 받아온 데이터를 온전히 매개변수로 일정시점까지 전달해주면,
이후엔 table instance가 모든 기능을 담당한다.

즉, 개발자가 기능 구현을 위해 신경써야할 부분을 테이블이 잡아준다.
정-말 편하다.


참고자료

Columns Definitions Guide
shadcn/ui Data Table
Significance of “extends {}”
Cannot find TData or TValue in the tanstack typescript library

© 2025 Geuni, Powered By Gatsby.