ReactでTanstack Table使っていろいろテーブルを操作してみる

ReactでTanstack Tableを使ってページネーション、ソート、フィルタリングを試してみましたので紹介します。StackBlitzでブラウザで動かせるサンプル付き。
2024.03.05

Reactでテーブル表示をする際に、いろいろ考えないといけないことが多いです。

ページネーション(Pagination)したいとか、ソート(Sorting)したいとか、フィルタリング(Filtering)したいとか…、いろいろあります。

これら全部を自前で実装するのは大変ですが、Tanstack Tableを使うと簡単に実装できるそうです。

ドキュメント見てTanstack Tableすごそうだなーと思うものの、機能が多すぎてどんなことできるのかいまいちピンときてないんですよね。

なんで、Tanstack Tableをざっと使ってみて具体的にどんなことができるのかを見てみたいと思います。

このブログでは、最終的にこんな感じのテーブルが作れるようになります。

Tanstack Tableとは

Tanstack Tableは、テーブル実装に必要な様々な機能を備えたHeadless UIライブラリです。

テーブル操作に必要なロジック部分とデザイン部分が分離されているため、柔軟にUIをカスタマイズできるのが特徴です。

テーブルの作成(ブログの紹介)

作り方はこっちのブログを参考にしてください。

このブログでは作った後からあれやこれやします。

基本のテーブル

まずは、基本のテーブルです。

useReactTable でテーブルを作成します。

columns でカラムを定義し、 data でデータを定義します。

data はWebAPIからの非同期な取得を想定して、 useEffect で非同期にデータを取得して、 useState でデータを適用しています。

(↓クリックすると埋め込んだStackBlitzでコードとプレビューページが表示されます)

Headless UIなので、スタイルは何もないです。

これだけシンプルなテーブルならTanstack Tableを使わず、 <table> タグで作ればいいと思います。

実際の開発で、こんなシンプルなテーブルを使うことはほとんど無いですね。

スタイルをつける

あまりにも簡素すぎるので、CSSでスタイルをつけます。

CSSは styles.css として別ファイルから読み込んでいるので、次のCSSを styles.css に追加してスタイルをつけます。

styles.css

table {
  border-collapse: collapse;
  border: 2px solid black;
  padding: 1px;
}

th {
  border: 1px dashed lightgray;
  border-bottom: 1px solid black;
  padding: 4px;
  background: lavender;
}

td {
  border: 1px dashed lightgray;
  padding: 4px;
}

ちょっとだけ見栄えがよくなりました。

データの表示を変える

テーブルのデータは、表示を変えたいことがよくあります。

例えば、何らかのフラグ真偽値を 有効無効 に変えたいとか、日付を yyyy/MM/dd に変えたいとか。

そういう時は、 ColumnDefcell を変えると、セルの表示を自分の好きなように変えることができます。

今回は、UNIXタイムスタンプ(ミリ秒)な登録日時(createdAt)の表示を、 yyyy/MM/dd HH:mm に変えてみます。

時間の取り扱いを自前で作るのがめんどくさいので、 date-fns を使います。

こんな感じにすれば、 date-fns を使って日付をフォーマットできます。

import { format } from 'date-fns';
import ja from 'date-fns/locale/ja';

const displayDate = format(new Date(user.createdAt), 'yyyy/MM/dd HH:mm', {
        locale: ja,
      })

次のように、テーブルの ColumnDefcell に表示したいReactコンポーネントを返す関数を定義することで、セルの表示を変えられます。

src/column.tsx

export const columns: ColumnDef<User>[] = [
...
  {
    accessorKey: 'createdAt',
    header: '登録日時(UNIX)',
    cell: ({ row }) => {
      const user = row.original;
      return format(new Date(user.createdAt), 'yyyy/MM/dd HH:mm', {
        locale: ja,
      });
    },
  },
...
];

そうすると、こんな感じで日付が表示されます。

ボタンの追加

テーブルのデータごとに 編集ボタン みたいな、何かしらのアクションを追加したいことがあります。

今回は、 編集ボタン を追加して、クリックしたらアラートを表示するようにしてみます。

アイコンは自分で作るのがめんどくさいので、 react-icons を使います。

次のように、テーブルの ColumnDef にカラム定義を追加して、 cell に表示したいアイコンとアクションを定義することで、行にアクションを追加できます。

src/column.tsx

import {
  TiEdit,
} from "react-icons/ti";

export const columns: ColumnDef<User>[] = [
  {
    id: "actions",
    cell: ({ row }) => {
      const user = row.original;
      return (
          <div
              style={{ cursor: "pointer" }}
              onClick={() =>
                  alert(`${user.id}:${user.email}の編集ボタンがクリックされました。`)
              }
          >
            <TiEdit />
          </div>
      );
    },
  },
  ...
];

そうすると、こんな感じで 編集ボタン がテーブルに表示されます。

ページネーション(Pagination)

表示するデータが多い場合、一度に全て表示するのではなく、ページごとに決まった件数を表示(ページネーション)したいです。

Tanstack Tableを使うとページネーションが簡単に実装できます。

useReactTableの引数に getPaginationRowModel を追加するだけです。

src/App.tsx

  const table = useReactTable<User>({
    columns,
    data,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

デフォルトでは、ページごとに10件表示され、最初のページが表示されます。

useReactTableinitialState で、ページごとの件数と、表示するページ数の初期値が設定できます。

src/App.tsx

  const initialPageIndex = 0;
  const initialPageSize = 10;

  const table = useReactTable<User>({
    columns,
    data,
    initialState: {
      pagination: {
        pageIndex: initialPageIndex,
        pageSize: initialPageSize,
      }
    },
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

現在のページサイズやページ番号を取得するには、 table.getState().pagination を参照します。

ページサイズを変更するには、 table.setPageSize を使います。

ページ番号を変更するには、 table.setPageIndex を使います。

その他、ページネーションに関するこれらの関数があります。(一部抜粋)

  • nextPage, previousPage: 次のページ、前のページに移動
  • canNextPage, canPreviousPage: 次のページ、前のページに移動できるかどうかを判定して真偽値を返す

これらの関数を使うと、こんな感じのページネーション対応のテーブルが簡単に作れます。 ついでに table.getState().pagination を使って、ページ番号やページサイズを表示してみます。

ソート(Sorting)

テーブルはユーザーにソートさせたい場合もあります。

Tanstack Tableを使うとソートも簡単に実装できます。

useReactTable の引数に getSortedRowModel を追加するだけで、内部的にはテーブルがソート可能な状態になります。

src/App.tsx

  const table = useReactTable<User>({
    columns,
    data,
    initialState: {
      pagination: {
        pageIndex: initialPageIndex,
        pageSize: initialPageSize,
      },
    },
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

例えば初期状態として、 id の降順でソートされた状態にしたい場合は、useReactTable に次のように追加することで実現できます。

  const table = useReactTable<User>({
    columns,
    data,
    initialState: {
      pagination: {
        pageIndex: initialPageIndex,
        pageSize: initialPageSize,
      },
      sorting: [{ id: "id", desc: true }],
    },
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

一応ソートが実現できました。 が、そうではなくて、ユーザーの操作でソートできるようにしたいです。

その場合、自分でUIを作る必要があります。 今回は、テーブルのヘッダーにこういうアイコンを表示してみましょう。

なんとなくソートできるような感じがしません?

ソートしたら、こういうアイコンを表示して、昇順、降順でソートされていることを明示するようにしてみます。

方針は決めたので、これを実装してみます。

テーブルヘッダーの表示を変えたい場合は、 ColumnDefheader を変えるとヘッダーの表示が変えられます。

src/column.tsx

import {
  TiArrowSortedDown,
  TiArrowSortedUp,
  TiArrowUnsorted,
} from "react-icons/ti";

const getSortIcon = (sortDirection: false | SortDirection): JSX.Element => {
  switch (sortDirection) {
    case "asc":
      return <TiArrowSortedUp />;
    case "desc":
      return <TiArrowSortedDown />;
    default:
      return <TiArrowUnsorted />;
  }
};

export const columns: ColumnDef<User>[] = [
  {
    accessorKey: "id",
    header: ({ column }) => {
      return (
        <div
          style={{ flex: "auto", alignItems: "center", cursor: "pointer"}}
          onClick={column.getToggleSortingHandler()}
        >
          ID{getSortIcon(column.getIsSorted())}
        </div>
      );
    },
  },
  ...
];

column.getToggleSortingHandler は、ソートの状態を 降順昇順ソート無し と替えるための関数です。 これをヘッダーをクリックしたときのイベントハンドラに設定します。

column.getIsSorted は、そのカラムがソートされているかどうかを返す関数です。これを使って、ソートされている場合にアイコンを変えるようにします。

これらの関数を使うと、こんな感じのソート対応のテーブルが簡単に作れます。 ついでに table.getState().sorting を使って、ソートの状態を表示してみます。

フィルタリング(Filtering)

テーブルのデータを検索して絞り込みたい場合もあると思います。

Tanstack Tableを使うとフィルタリングも簡単に実装できます。

useReactTable の引数に getFilteredRowModel を追加するだけで、内部的にはテーブルがフィルタリング可能な状態になります。

src/App.tsx

  const table = useReactTable<User>({
    columns,
    data,
    initialState: {
      pagination: {
        pageIndex: initialPageIndex,
        pageSize: initialPageSize,
      },
    },
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  });

例のごとく、このままだとユーザーがフィルタリングできるUIを表示できるわけではありません。

ユーザーの操作でフィルタリングできるようにするには、自分でUIを作る必要があります。

フィルタリングしたい値のセットは、 columnsetFilterValue を使うとできます。

セットした値は、 columngetFilterValue で確認できます。

カラム自体は、 tablegetColumn を使って取得できます。

これらの関数を使うと、こんな感じでフィルタリング機能が簡単に作れます。 ついでに table.getState().columnFilters を使って、フィルタリングの状態を表示してみます。

グローバルフィルタリング(Global Filtering)

1項目だけでなく、複数の項目を横断的に検索したい場合もあります。

そんな時は、グローバルフィルタリングを使います。

グローバルフィルタリングの値のセットは、 tablesetGlobalFilter を使ってできます。

セットした値は、 tablegetState().globalFilter で取得できます。

これで、全項目に対して横断的に検索できるようになります。

だけども、実際には検索対象外としたい項目もあると思います。

今回で言えば、 登録日時 は検索対象外としたいです。 ID で検索しようと思ってるのに、 登録日時 の日時まで検索対象になると鬱陶しそうです。

そういう時は、次のようにテーブルの ColumnDefenableGlobalFilterfalse にしておくと、グローバルフィルタリングの対象外になります。

src/column.tsx

export const columns: ColumnDef<User>[] = [
  ...
  {
    accessorKey: "createdAt",
    header: sortableHeader('登録日時(UNIX)'),
    cell: ({ row }) => {
      const user = row.original;
      return format(new Date(user.createdAt), "yyyy/MM/dd HH:mm", {
        locale: ja,
      });
    },
    enableGlobalFilter: false,
  },
  ...
];

これらの関数を使うと、こんな感じでグローバルフィルタリング機能が簡単に作れます。 ついでに table.getState().globalFilter を使って、フィルタリングの状態を表示してみます。

検索対象に日本語が入ってくると、カタカナ、ひらがな、全角、半角などいろいろな表記があるので、それらを考慮して検索したい気がしてきますが、今回は割愛します。

フロントで日本語のあいまい検索って簡単にできるんですかね?誰か教えてください。

フィルタリングの拡張(Filtering+)

単純な文字列検索だけでなく、複雑な検索をしたい場合もあります。

例えば、今回は 登録日時 があるので、特定の日付の範囲のデータを検索したいとか、そんな場合を考えてみます。

フィルタリングのロジックは、デフォルトだとTanstack Tableがいい感じにやってくれますが、自分でフィルタリング条件を定義したい場合は、 ColumnDeffilterFn でフィルタリングのロジックを定義できます。

次の FilterFn の定義からわかる通り、 filterValue の型が any で何でも入れられます。

export type FilterFn<TData extends AnyData> = {
  (
    row: Row<TData>,
    columnId: string,
    filterValue: any,
    addMeta: (meta: any) => void
  ): boolean
  resolveFilterValue?: TransformFilterValueFn<TData>
  autoRemove?: ColumnFilterAutoRemoveTestFn<TData>
  addMeta?: (meta?: any) => void
}

と、いうことは columnsetFilterValue 入れる値は 文字列 じゃなくても何でもいいわけです。

そういうわけで、 filterValue に日付の範囲を入れてフィルタリングできるようなロジックにしてみましょう。

filterValue{from?: string, to?: string} な型で、日付の書式は yyyy-MM-dd なデータが入ってくることを前提とします。後々、日付の範囲を入れるUIを作るときに、この書式となるようにします。

from が設定されている場合は from 以降のデータを、 to が設定されている場合は to 以前のデータを絞り込むようなロジックはこんな形でできます。

src/column.tsx

import { parse } from "date-fns";
...
    filterFn: (row, _, filterValue) => {
      const { from, to } = filterValue as { from?: string; to?: string };
      const createdAt = row?.original?.createdAt;

      return (
        (!from ||
          parse(from, "yyyy-MM-dd", new Date()).getTime() <= createdAt) &&
        (!to || createdAt <= parse(to, "yyyy-MM-dd", new Date()).getTime())
      );
    },
...

先ほど、 columnsetFilterValue を使うと、フィルタリングの値をセットできると書きました。 この setFilterValue の引数は、値だけでなく、古い値を引数にした関数を使うことができます。

つまり、setFilterValue の引数に次のようなupdate関数をセットすることで、 filterValue の一部の値だけ更新できます。

const updater = (
  old: { from?: string; to?: string }
) => {
       return {
         ...old,
         from: "newFromValue",
       };
     }

これらの関数を使うと、こんな感じで日付範囲のフィルタリング機能も作れます。

終わりに

今回は、Tanstack Tableを使ったテーブルの実装をやってみました。

テーブルの実装に必要な機能が一通り揃っていて多機能でめっちゃ便利だなと思った反面、ドキュメントを読むのがちょっと難しいなと思ったので、具体例多めでざっと使い方を紹介してみました。

Exampleが豊富なので、コードを読んで理解するのが一番早そうです。