EY-Office ブログ

Headless UIのTanStack Tableがおもしろい

最近、TanStack Query, TanStack Router, TanStack TableなどのTanStackというライブラリーの記事を良く目にするようになってきました。
TanStack Queryは、以前はReact Queryと呼ばれていた非同期通信を簡単に書けるライブラリーで、知ってる人は多いと思います。

What Is TanStackというブログによるとTanはTypeScript, Next.js, and Reactに由来する名前だと書かれています(Next.jsの色は薄いような気がしますが・・・)。 TanStackのホームページには以下の画像のように High-quality open-source software for web developers. と書かれています。

今回はTanStack Tableに付いて書きます。Headless UIって何なんでしょうか?

Astro https://tanstack.com より

準備

TanStackのGETTING STARTEDを読んでみたあのですが、いまひとつ使い方がわからなかったので、ブログ等の記事をよみながら使い方をしらべました。最終的にはTanStackのEXAMPLESのコードが役立ちました(CodeSandboxのリンクをクリックして動かすのが良いと思います)。

今回はバックエンドにjson-serverを使いました、これはJSON形式のデータファイルを準備するだけでREST APIのバックエンドが簡単に作れます。

データのはFree Data Sets & Dataset Samplesで見つけたワイン(正確にはワインテースティング)のデータを使いました。🍷

Reactの開発環境はViteです。

シンプルなテーブル

まずは、シンプルなサンプルコードです。バックエンドからTanStack Queryでデータを取得し、TanStack Tableに表示しています。

コードは以下のようになります

  • ① CSSは最低限のものを準備しました
  • ② 表示するデータの定義、Free Data Sets & Dataset Samplesで見つけたデータには多数の項目がありましたが最低限にしました
  • ③ テーブルの列(カラム)定義、ここではシンプルですがデータと無関係のカラムやグループ、表示のフォーマットなど色々な定義が出来ます
  • TanStack Queryを使ってバックエンドから100件のデータを取得しています
    • TanStack Queryを使う事でデータやローディング中のステート管理(useState)や取得のライフサイクルuseEffectのコードは不要になります
  • ⑤ ここでテーブル管理用のtableオブジェクトが作られます
    • getCoreRowModel: にデータ管理用モデルを指定します、ここでは基本的なgetCoreRowModelを指定しています。詳細はRow Models Guideを見てください
  • ⑥ 表示コード
    • ローディング中はLoading...を表示します
    • 一見複雑そうですが、読んでみると普通のコードですね
  • table.getHeaderGroups()でヘッダーの情報を取得します
    • 列定義にグループがある場合はグループ名表示は複数行になります
  • ⑧ テーブルの要素データの表示はflexRender()ユーティリティ関数を使います
  • getRowModel()でテーブルの全行データを取得します
  • getVisibleCells()で行内の要素データを取得します
import { ColumnDef } from '@tanstack/react-table';
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useQuery } from '@tanstack/react-query';
import './App.css';                                   // ← ①

type Wine = {                                         // ← ②
  id: number,
  country: string,
  variety: string,
  winery: string,
  title: string
};

const columns: ColumnDef<Wine>[] = [                  // ← ③
  { accessorKey: 'id',      header: 'ID' },
  { accessorKey: 'country', header: '国' },
  { accessorKey: 'variety', header: '品種' },
  { accessorKey: 'winery',  header: 'ワイナリー' },
  { accessorKey: 'title',   header: '名前' },
];

export default function App() {
  const { data, isLoading } = useQuery({              // ← ④
    queryKey: ['wines'],
    queryFn: async () => {
      const res = await fetch('http://localhost:3030/wines?_limit=100');
      return res.json();
    }
  });

  const table = useReactTable<Wine>({                // ← ⑤
    columns,
    data,
    getCoreRowModel: getCoreRowModel(),
  });

  if (isLoading) {                                   // ← ⑥
    return <p>Loading...</p>;
  } else {
    return (
      <>
        <h1>Wine</h1>
        <table>
          <thead>
            {table.getHeaderGroups().map((headerGroup) => (   // ← ⑦
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <th key={header.id} colSpan={header.colSpan}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(                           // ← ⑧
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => {        // ← ⑨
              return (
                <tr key={row.id}>
                  {row.getVisibleCells().map((cell) => {    // ← ⑩
                    return (
                      <td key={cell.id}>
                        {flexRender(                        // ← ⑧
                          cell.column.columnDef.cell,
                          cell.getContext()
                        )}
                      </td>
                    );
                  })}
                </tr>
              );
            })}
          </tbody>
        </table>
      </>
    )
  }
}

Headless UIとは?

上のコードでわかるように、TanStack TableにはUI要素は含んでいません。テーブルにはソートやページング、フィルタリング、カラムの並び替えなど多数の機能が要求されますが、TanStack Tableは、高機能なテーブルを実現するためのデータ管理やAPIを提供してくれるライブラリーです。

Reactのメリットとして多数の便利なコンポーネントがOSSまたは有料で公開されている事もあります。awesome-react-componentsのTableを見ると多数のテーブル用のコンポーネントがあります。

ただし、開発しているプロジェクトがスタイリングにTailwind CSSを採用している場合はMaterial UIベースのテーブル・コンポーネントは採用できませんよね。
そのような意味で、Heaadless UIでテーブル表示のベースを作り、UIは自由に選べるのはとてもありがたいですよね。

Client-Side Pagination

シンプルなテーブルでは、あまりTanStack Tableのありがたさが感じられないと思うので、テーブルにページング機能を追加してみましょう。
まずは全データをクライアント側に取得してから、クライアント(JavaScript)だけでページングするサンプルです。

コードは少ししか増えません

  • ① ページング用のステートpaginationを定義
    • 開始ページ(pageIndex)、ページの行数(pageSize)のオブジェクトです
  • ② データ取得部分は同じです
  • ③ テーブル管理用のtableオブジェクトの作成
  • ④ デバッグ指定、デバッグ情報がconsoleに表示されます
  • ⑤ getPaginationRowModelにページネーション用のモデルを指定
  • ⑥ ページ切り替え用関数の設定、paginationステートの設定関数を指定
  • ⑦ テーブルに関連するステートを設定
  • ⑧ ページを戻すボタンの処理
    • table.previousPage()を呼び出す事でpaginationステートを変更します
    • また0ページ以前に行かないようにtable.getCanPreviousPage()関数の値でボタンをon/offしています
  • 現在ページ / 全ページ の表示
    • 現在のページはpaginationステートから取得
    • 全ページ数はtable.getPageCount()で取得
  • ⑩ ページを進めるボタンの処理、⑧と同様です
import { useState } from 'react';
import { ColumnDef, getPaginationRowModel } from '@tanstack/react-table';
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useQuery } from '@tanstack/react-query';
import './App.css';

// type Wine = {                           // 型定義は同じなので省略
// const columns: ColumnDef<Wine>[] = [    // カラム定義は同じなので省略

export default function App() {
  const [pagination, setPagination] =
    useState({pageIndex: 0, pageSize: 10});   // ← ①

  const { data, isLoading } = useQuery({      // ← ②
    queryKey: ['wines'],
    queryFn: async () => {
      const res = await fetch('http://localhost:3030/wines?_limit=200');
      return res.json()
    },
  });

  const table = useReactTable<Wine>({                   // ← ③
    columns,
    data,
    debugTable: true,                                   // ← ④
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),     // ← ⑤
    onPaginationChange: setPagination,                  // ← ⑥
    state: { pagination },                              // ← ⑦
  });

  if (isLoading) {
    return <p>Loading...</p>;
  } else {
    return (
      <>
        <h1>Wine</h1>
        <div style={{margin: '0 0 10px 20px'}}>
        <button onClick={() => table.previousPage()}             {// ← ⑧ }
                disabled={!table.getCanPreviousPage()}>
            &lt;
          </button>
          <span style={{margin: '0 10px'}}>
            {pagination.pageIndex + 1} / {table.getPageCount()}  {// ← ⑨ }
          </span>
          <button onClick={() => table.nextPage()}               {// ← ⑩ }
                  disabled={!table.getCanNextPage()}>
            &gt;
          </button>
        </div>
        <table>
          ・・・・ テーブル表示のコードは同じなので省略
        </table>
      </>
    )
  }
}

Manual Server-Side Pagination

前のページネーションでは最初に全データを取得してしまいますが、TanStack Tableにはバックエンドのページネーション機能を使い必要なページのみクライアント側で扱うページネーションも可能です。

コードの変更点は、今回も僅かです

  • ① ページング用のステートpagination定義は同じ
  • ② 全データ件数を保存するためのステートrowCountを定義
  • ③ useQueryのキャッシュ機能を使うためにkeyにページ番号を追加しページ単位でキャッシュするように設定
  • json-serverのページネーションを使いページ単位でデタを取得
    • _pageでページ番号、_per_pageでページの大きさを指定
  • ⑤ json-serverの戻り値のヘッダー情報X-Total-Countに全データ件数が入っているので、これをrowCountテートに保存
  • ⑥ キャッシュの廃棄時間を設定
  • ⑦ テーブル管理用のtableオブジェクトの作成
  • ⑧ コードでページネーションする設定
  • ⑨ 全データの件数を指定
import { useState } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { flexRender, getCoreRowModel, useReactTable} from '@tanstack/react-table';
import './App.css'
import { useQuery } from '@tanstack/react-query';

// type Wine = {                           // 型定義は同じなので省略
// const columns: ColumnDef<Wine>[] = [    // カラム定義は同じなので省略

export default function App() {
  const [pagination, setPagination] =
      useState({pageIndex: 0, pageSize: 10});      // ← ①
  const [rowCount, setRowCount] = useState(1);     // ← ②

  const { data, isLoading } = useQuery({
    queryKey: ['wines', pagination.pageIndex],                       // ← ③
    queryFn: async () => {
      const res = await fetch(`http://localhost:3030/wines?` +       // ← ④
        `_page=${pagination.pageIndex + 1}&_per_page=${pagination.pageSize}`);
      setRowCount(Number(res.headers.get("X-Total-Count") ?? "1"));  // ← ⑤
      return res.json();
    },
    staleTime: 5000                                                  // ← ⑥
  });

  const table = useReactTable<Wine>({                // ← ⑦
    columns,
    data,
    debugTable: true,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true,                          // ← ⑧
    rowCount: rowCount,                              // ← ⑨
    onPaginationChange: setPagination,
    state: { pagination },
  });

// 表示(JSX)はClient-Side Paginationと同じ

Material UIにしてみた

Manual Server-Side PaginationのコードをMaterial UIできれいな見かけにしてみました。

  • ① 1行毎にバックグラウンドの色を変えたり最終行の線を消したテーブル行コンポーネント
  • ② Material UIのテーブルのページネーションコンポーネント<TablePagination>を使ってみました
    • 必要な情報や関数はtableオブジェクトやpagnationオブジェクトから取得しています
import { useState } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { flexRender, getCoreRowModel, useReactTable} from '@tanstack/react-table';
import { Box, Paper, Table, TableBody, TableCell, TableContainer, TableHead,
  TablePagination, TableRow, Typography, styled } from '@mui/material';
import { useQuery } from '@tanstack/react-query';

// type Wine = {                           // 型定義は同じなので省略
// const columns: ColumnDef<Wine>[] = [    // カラム定義は同じなので省略

const StyledTableRow = styled(TableRow)(({ theme }) => ({   // <- ①
  '&:nth-of-type(odd)': {
    backgroundColor: theme.palette.action.hover,
  },
  '&:last-child td, &:last-child th': {
    border: 0,
  },
}));

export default function App() {
  // Appの最初の部分は同じなので省略

  if (isLoading) {
    return <p>loading...</p>;
  } else {
    return (
      <Box sx={{p: 4}}>
        <Typography variant="h2" gutterBottom>Wine</Typography>
        <TableContainer component={Paper} sx={{p: 2}}>
        <Table>
          <TableHead>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableCell key={header.id} colSpan={header.colSpan}
                    sx={{fontWeight: 600}}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableCell>
                ))}
              </TableRow>
            ))}
          </TableHead>
          <TableBody>
            {table.getRowModel().rows.map((row) => {
              return (
                <StyledTableRow key={row.id}>
                  {row.getVisibleCells().map((cell) => {
                    return (
                      <TableCell key={cell.id}>
                        {flexRender(
                          cell.column.columnDef.cell,
                          cell.getContext()
                        )}
                      </TableCell>
                    );
                  })}
                </StyledTableRow>
              );
            })}
          </TableBody>
        </Table>
        </TableContainer>
        <TablePagination                                // <- ②
            rowsPerPageOptions={[-1]}
            component="div"
            count={table.getPageCount()}
            rowsPerPage={pagination.pageSize}
            page={pagination.pageIndex}
            onPageChange={(_e: unknown, newPage: number) =>
              table.setPageIndex(newPage)}
          />
      </Box>
    )
  }
}

まとめ

再度かきますがTanStack TableにはUI要素は含んでないライブラリーで、ソートやページング、フィルタリング、カラムの並び替えなどの高機能なテーブルを実現するためのデータ管理やAPIを提供してくれるライブラリーです。

UIが自由に選べるのは素晴らしいことだと思います。さらにReact以外VueやSolid, Svelte、さらに素のJavaScriptでも使えるようです。

他にもTanStackにはおもしろそうなライブラリーがあるので機会があれば取り上げたいと思います。

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ