TanStack Table によるソートの実装と CSS-in-JS への組み込み

## はじめに

React アプリケーションにおいてテーブルの UI を作成したいというのはよくあると思うが、3rd パーティ製ライブラリを利用するのか、自分でゴリゴリ書くのか、迷いポイントである。というのも、前者の場合は too much であったりするし、かといって自分で書くのもめんどくさい。
そういうことを考えながら途方に暮れていたとき、**TanStack Table** というものを見つけ、上記の課題を解決してくれそうだったので使ってみることにした。

https://tanstack.com/table/latest

## 探していたもの

テーブルを構築する上で、「スタイルを強制されない」というのが条件であった。例えば、自分で css をゴリゴリ書いてスタイリングをしている画面に対して、「そこまできめ細かなスタイルをカスタマイズできない MUI ベースのテーブルライブラリ」みたいなものを導入するとそこだけ浮いてしまう。
そのため、ヘッドレス UI のアーキテクチャを採用している本ライブラリは、非常に相性が良かった。

## この記事で検証したこと

基本的なテーブルを表示させることに加え、ソート機能の実装を行なった。
また前述のように、自由にスタイルできる環境にテーブルコンポーネントを導入した。
具体的には、TanStack Table で構築されたテーブルに CSS-in-JS ライブラリ (`linaria`) でスタイリングを行なった。

## 環境・前提

```
- react: 18.3.1
- next: 14.2.3
- linaria 関係:
	- @linaria/core: 6.2.0
	- @linaria/react: 6.2.1
	- @linaria/babel-preset: 5.0.4
```

_※Next.js および linaria はインストール・設定済みとする。_

linaria については、以下の記事をご参考まで。

- Next.js 14 でのセットアップ方法
https://qiita.com/yaskitie/items/122425573d5e511b8f4e

- linaria の紹介記事
https://qiita.com/yaskitie/items/b1aec0d4f9c1fd598634

## 作業

### 参考文献

設定して行くにあたり、TanStack 側の必要な情報 (実装上のガイド、API 仕様、使用例 etc) はここに全てある。
https://tanstack.com/table/latest/docs/introduction

特に、**使用例**はとりあえずすぐ画面に出していじりながら修正を加えて行くのには最適な情報だ。
e.g. https://tanstack.com/table/v8/docs/framework/react/examples/basic

### インストール

```sh
# install package
npm install @tanstack/react-table

# package version installed
npm list @tanstack/react-table
## @tanstack/react-table@8.17.3
```

### ダミーデータ

テーブルに表示するデータを以下のように用意した。

```ts:types/twice.ts
import type { TTwice } from 'types/twice';

export const dummyData: TTwice[] = [
  {
    name: 'Nayeon',
    age: 28,
    birthday: new Date('September 22, 1995'),
    birthplace: 'Seoul',
  },
  {
    name: 'Jeongyeon',
    age: 27,
    birthday: new Date('1 November 1996'),
    birthplace: 'Gyeonggi-do',
  },
  {
    name: 'Momo',
    age: 27,
    birthday: new Date('November 9, 1996'),
    birthplace: 'Kyoto',
  },
  {
    name: 'Sana',
    age: 27,
    birthday: new Date('December 29, 1996'),
    birthplace: 'Osaka',
  },
  {
    name: 'Jihyo',
    age: 27,
    birthday: new Date('February 1, 1997'),
    birthplace: 'Gyeonggi-do',
  },
  {
    name: 'Mina',
    age: 27,
    birthday: new Date('March 24, 1997'),
    birthplace: 'Hyogo',
  },
  {
    name: 'Dahyun',
    age: 26,
    birthday: new Date('May 28, 1998'),
    birthplace: 'Gyeonggi-do',
  },
  {
    name: 'Chaeyoung',
    age: 25,
    birthday: new Date('April 23, 1999'),
    birthplace: 'Seoul',
  },
  {
    name: 'Tzuyu',
    age: 25,
    birthday: new Date('June 14, 1999'),
    birthplace: 'Tainan',
  },
];
```

データの型定義は以下の通り。


```ts:types/data.ts
const birthplaces = [
  'Seoul',
  'Gyeonggi-do',
  'Kyoto',
  'Osaka',
  'Hyogo',
  'Tainan',
] as const;
type TBirthplace = (typeof birthplaces)[number];

export type TTwice = {
  name: string;
  age: number;
  birthday: Date;
  birthplace: TBirthplace;
};
```

### 基本的なテーブル

まずは、テーブルを表示するだけのコンポーネントを作成する。

```tsx:TwiceTable.tsx
'use client';

import type { ColumnDef } from '@tanstack/react-table';
import type { TTwice } from 'types/twice';
import {
  flexRender,
  getCoreRowModel,
  useReactTable,
} from '@tanstack/react-table';
import { useMemo, useState } from 'react';
import React from 'react';
import { dummyData } from 'types/data';

export const TwiceTable = () => {
  const columns = useMemo<ColumnDef<TTwice>[]>(
    () => [
      {
        accessorKey: 'name',
        header: () => <span>NAME</span>,
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'age',
        header: () => <span>AGE</span>,
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'birthday',
        header: () => <span>BIRTHDAY</span>,
        cell: (info) =>
          String(info.getValue()).split(' ').slice(0, 4).join(' '),
      },
      {
        accessorKey: 'birthplace',
        header: () => <span>BIRTHPLACE</span>,
        cell: (info) => info.getValue(),
      },
    ],
    [],
  );

  const [data, _setData] = useState(dummyData);

  const table = useReactTable({
    data,
    columns,
    columnResizeMode: 'onChange',
    getCoreRowModel: getCoreRowModel(),
    defaultColumn: {
      size: 400,
    },
  });

  return (
    <>
      <span style={{ fontSize: '1.2rem' }}>TWICE MEMBER</span>
	<table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th key={header.id}>
                  {header.isPlaceholder ? null : (
                    <div>
                      {flexRender(
                        header.column.columnDef.header,
                        header.getContext(),
                      )}
                    </div>
                  )}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
        <tfoot>
          {table.getFooterGroups().map((footerGroup) => (
            <tr key={footerGroup.id}>
              {footerGroup.headers.map((header) => (
                <th key={header.id}>
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.footer,
                        header.getContext(),
                      )}
                </th>
              ))}
            </tr>
          ))}
        </tfoot>
      </table>
    </>
  );
};
```

見た目は、なんてことないこんな感じ。

![スクリーンショット 2024-07-08 2.24.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/475918e3-e823-83c4-d87a-22ec64db8ccf.png)


#### ポイント解説

- `columns` では、`accessorKey` でどのデータに対応するのかを設定し、各カラムのヘッダーとセルに表示する内容を調整する
- `useReactTable` フックにより、テーブルコンポーネントに流す state を構成する
- `return` 以下で、`table` 情報を元にレンダリングする

### データのソート

では、ここにデータをソートする機能を入れてみる。

変更点は以下の通り。

```diff_tsx:TwiceTable.tsx
'use client';

- import type { ColumnDef } from '@tanstack/react-table';
+ import type { ColumnDef, SortingFn, SortingState } from '@tanstack/react-table';
import type { TTwice } from '@/types/log';
import {
  flexRender,
  getCoreRowModel,
+ getSortedRowModel,
  useReactTable,
} from '@tanstack/react-table';
import { useMemo, useState } from 'react';
import React from 'react';
import { dummyData } from '@/types/data';

export const Log = () => {
+ const sortBirthplaceFn: SortingFn<TTwice> = (rowA, rowB, _columnId) => {
+   const statusA = rowA.original.birthplace;
+   const statusB = rowB.original.birthplace;
+   const order = ['Seoul', 'Gyeonggi-do', 'Kyoto', 'Osaka', 'Hyogo', 'Tainan'];
+   return order.indexOf(statusA) - order.indexOf(statusB);
+ };
  const columns = useMemo<ColumnDef<TTwice>[]>(
    () => [
      {
        accessorKey: 'name',
        header: () => <span>NAME</span>,
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'age',
        header: () => <span>AGE</span>,
        cell: (info) => info.getValue(),
      },
      {
        accessorKey: 'birthday',
        header: () => <span>BIRTHDAY</span>,
        cell: (info) =>
          String(info.getValue()).split(' ').slice(0, 4).join(' '),
      },
      {
        accessorKey: 'birthplace',
        header: () => <span>BIRTHPLACE</span>,
        cell: (info) => info.getValue(),
+       sortingFn: sortBirthplaceFn,
      },
    ],
    [],
  );

  const [data, _setData] = useState(dummyData);

+ const [sorting, setSorting] = useState<SortingState>([]);
  const table = useReactTable({
    data,
    columns,
    columnResizeMode: 'onChange',
    getCoreRowModel: getCoreRowModel(),
    defaultColumn: {
      size: 400,
    },
+   getSortedRowModel: getSortedRowModel(),
+   onSortingChange: setSorting,
+   state: {
+     sorting,
+   },
  });

  return (
    <>
      <span style={{ fontSize: '1.2rem' }}>TWICE MEMBER</span>
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th key={header.id}>
                  {header.isPlaceholder ? null : (
                    <div
+                     className={
+                       header.column.getCanSort()
+                         ? 'cursor-pointer select-none'
+                         : ''
+                     }
+                     onClick={header.column.getToggleSortingHandler()}
                    >
                      {flexRender(
                        header.column.columnDef.header,
                        header.getContext(),
                      )}
+                     {{
+                       asc: ' ↑',
+                       desc: ' ↓',
+                       false: sorting.length > 0 ? '' : ' ↑↓',
+                     }[header.column.getIsSorted() as string] ?? null}
                    </div>
                  )}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
        <tfoot>
          {table.getFooterGroups().map((footerGroup) => (
            <tr key={footerGroup.id}>
              {footerGroup.headers.map((header) => (
                <th key={header.id}>
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.footer,
                        header.getContext(),
                      )}
                </th>
              ))}
            </tr>
          ))}
        </tfoot>
      </table>
    </>
  );
};
```

実行結果はこのような感じ。

![画面収録 2024-07-08 2.49.23.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/80cfe4d2-b7e2-41cd-89ed-26ff9e47c150.gif)


#### ポイント解説

- ソートアルゴリズムは、`columns` > `sortingFn` で指定する
	- データが文字列(`NAME`)、数字(`AGE`) および Date 型(`BIRTHDAY`)の場合はデフォルトで asc/desc できる
	- カスタムソートロジックを簡単に組める
		- 例では、`sortBirthplaceFn` というカスタムロジックの中で、「`birthplace` の順番が **['Seoul', 'Gyeonggi-do', 'Kyoto', 'Osaka', 'Hyogo', 'Tainan']**」となるようにしている
	- ソート中かどうかの状態を `sorting` で管理しており、ソート内容に応じたレンダリングを行う

### Linaria との組み合わせでコンポーネントをリファインする

Css-in-JS によるコンポーネントを作成し、それを元のコンポーネントから呼び出す。

```tsx:components/Table.tsx
import { styled } from '@linaria/react';

type RGB = `rgb(${number}, ${number}, ${number})`;
type RGBA = `rgba(${number}, ${number}, ${number}, ${number})`;
type HEX = `#${string}`;
type NAME = `${string}`;

export type TColor = RGB | RGBA | HEX | NAME;

interface ITable {
  baseBackgroundColor?: TColor;
  headerBackgroundColor?: TColor;
  borderColor?: TColor;
  radius?: number;
  headerHeight?: number;
  rowHeight?: number;
}

export const Table = styled.table<ITable>`
  background-color: ${(p) => p.baseBackgroundColor ?? '#ffffff'};
  border: 1px solid ${(p) => p.borderColor ?? '#cccccc'};
  border-radius: ${(p) => p.radius ?? 8}px;
  border-spacing: 0;
  > thead {
    background-color: ${(p) => p.headerBackgroundColor ?? '#f4f4f4'};
    > tr > th {
      border-bottom: 1px solid ${(p) => p.borderColor ?? '#cccccc'};
      font-weight: 400;
      height: ${(p) => p.headerHeight ?? 40}px;
      padding: 1px 8px;
      text-align: left;
    }
    > tr {
      > th:nth-of-type(1) {
        border-radius: ${(p) => p.radius ?? 8}px 0 0;
        width: calc(var(--header-1-size) * 1px);
      }
      > th:nth-of-type(2) {
        width: calc(var(--header-2-size) * 1px);
      }
      > th:nth-of-type(3) {
        width: calc(var(--header-3-size) * 1px);
      }
      > th:nth-of-type(4) {
        border-radius: 0 ${(p) => p.radius ?? 8}px 0 0;
        width: calc(var(--header-4-size) * 1px);
      }
    }
  }
  > tbody {
    > tr {
      /* row height */
      height: ${(p) => p.rowHeight ?? 32}px;
      > td {
        padding: 1px 8px;
      }
    }
    > tr:not(:last-child) > td {
      border-bottom: 1px solid ${(p) => p.borderColor ?? '#cccccc'};
    }
  }
`;
```


```diff_tsx:TwiceTable.tsx
+ import { Table } from '@/components/common/Table';
  ...

+ const columnSizeVars = useMemo(() => {
+   const headers = table.getFlatHeaders();
+   const colSizes: { [key: string]: number } = {};
+   for (let i = 0; i < headers.length; i++) {
+     const header = headers[i]!;
+     colSizes[`--header-${i + 1}-size`] = header.getSize();
+     colSizes[`--col-${i + 1}-size`] = header.column.getSize();
+   }
+   return colSizes;
+ }, [table.getState().columnSizingInfo, table.getState().columnSizing]);

  return (
    <>
      <span style={{ fontSize: '1.2rem' }}>TWICE MEMBER</span>
-     <table>
+     <Table style={{ ...columnSizeVars }}>
+       headerBackgroundColor="LightSalmon"
+       baseBackgroundColor="pink"
+       borderColor="tomato"
+     >
        <thead>
        ...
```

![スクリーンショット 2024-07-08 10.02.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/671630/c1ddca19-f663-a809-e782-8b012ef6d28e.png)



#### ポイント解説

- `<Table>` コンポーネントは、React ビルトインの `<table>` を拡張し、その配下の要素についてスタイル情報を上書きする
    - `columnSizeVars` は、各カラムのサイズを CSS カスタムプロパティとして持つようにし、それを \<Table\> に渡すことで使用できるようにしている(e.g. `--header-1-size` など)
	- \<Table\> には引数を設定して、外部から情報を注入することも可能(例では、`ITable` で定義した ６ つのプロパティのうち、3 つを引数で与えている)
- スタイリングとデータ・ロジックを分離することで管理しやすくする
	- 仮に Css-in-JS として切り出したスタイリングを元のコンポーネントのレンダリング部分に入れ込むと、**ロジックとスタイルの記述が混在し、可視性やメンテナンス性が低下してしまう**

## 終わりに

TanStack Table を利用することで、基本的なテーブル構築、ソート機能の実装、そして CSS-in-JS ライブラリとのコラボでいい感じにテーブルを設計することができた。
**実装例を見るだけでなんとなく使い方がわかるのがとても良い体験だと思った。**　説明書がなくても使い始められ、必要に応じて詳しく確認する、というのはとても今風だ。
TanStack の他のライブラリも気になるので、折に触れて試していきたい。