ReactでTanStack Tableを使い、並べ替えのできる表を作る方法

​​React​TanStack Table,TypeScript,アクセシビリティ

TanStack Table の useReactTable に getSortedRowModel を渡すと、見出しをクリックするだけで並べ替わる表を作れます。見た目の HTML は自分で書き、並べ替えの計算と、クリックしたときに次の並べ方を決める処理を TanStack Table に任せる形です。

検証環境で6行の表を作り、クリックのたびに並び順と並べ替えの状態がどう変わるかを記録しました。あわせて、日本語の名前の並び方と、行が少ない表で文字列の比べ方が変わる点を調べました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは @tanstack/react-table 8.21.3(中で使われる @tanstack/table-core も 8.21.3)、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Chromium 141(Playwright 1.56.1 で操作)で動かしました。StrictMode では包んでいません。

サーバー側で並べ替える manualSorting、ページ分けや絞り込みとの組み合わせ、たくさんの行を並べたときの速さ、React Compiler を使った場合の4つは、この記事では試していません。

インストールするのは @tanstack/react-table だけ

TanStack Table のインストールの説明では、React で使うときは @tanstack/react-table を入れるように書かれています。React 16.8、17、18、19 で動くとされています。

text
npm install @tanstack/react-table

同じページには、React 19 では動くものの、React Compiler と組み合わせると動かない場合があるという注意も書かれています。この記事では React Compiler は使っていません。

データと列の定義は、コンポーネントの外に置く

表に出すデータは、猫の名前、年齢、体重、ひとことを入れた6行にしました。

ts
export type Cat = { name: string; age: number; weight: number; memo: string };

export const cats: Cat[] = [
  { name: 'たま', age: 3, weight: 4.2, memo: '窓辺が好き' },
  { name: 'ミケ', age: 5, weight: 3.8, memo: '人見知り' },
  { name: 'そら', age: 3, weight: 5.1, memo: 'よく食べる' },
  { name: 'クロ', age: 8, weight: 4.6, memo: '夜に元気' },
  { name: 'あずき', age: 5, weight: 3.5, memo: '箱に入る' },
  { name: 'ハチ', age: 1, weight: 2.9, memo: 'まだ子ねこ' },
];

列の定義には createColumnHelper を使いました。列の定義のガイドによると、createColumnHelper に行の型を渡して呼ぶと、型の付いた列の定義を作れます。accessor('name', …) のように、行のどのプロパティを出すかを名前で書きます。

ts
import { createColumnHelper } from '@tanstack/react-table';
import type { Cat } from './cats';

const columnHelper = createColumnHelper<Cat>();

// 列の定義(コンポーネントの外に置き、毎回の描画で作り直さない)
export const columns = [
  columnHelper.accessor('name', { header: '名前' }),
  columnHelper.accessor('age', { header: '年齢' }),
  columnHelper.accessor('weight', { header: '体重(kg)' }),
  columnHelper.accessor('memo', { header: 'ひとこと', enableSorting: false }),
];

データも列も、コンポーネントの外で定義しています。TanStack Table のよくある質問では、columns や data を描画のたびに新しい配列として作ると、描画が止まらなくなることがあると説明されています。コンポーネントの中で作るときは、useMemo や useState で同じ参照を保つように書かれています。

「ひとこと」の列には enableSorting: false を付けて、並べ替えられないようにしました。

useReactTable に getSortedRowModel を渡す

表のコンポーネントでは、並べ替えの状態を useState で持ち、state.sorting と onSortingChange で useReactTable に渡します。並べ替えのガイドにある、状態を自分で持つ書き方です。ブラウザの中で並べ替えるには、getSortedRowModel() も渡します。

tsx
import { useState } from 'react';
import {
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  useReactTable,
  type ColumnDef,
  type SortingState,
} from '@tanstack/react-table';
import { cats, type Cat } from './cats';

const arrows = { asc: ' ▲', desc: ' ▼' };
const ariaSort = { asc: 'ascending', desc: 'descending' } as const;

// 見出しをクリックすると並べ替わる表
export function CatTable({ columns }: { columns: ColumnDef<Cat, any>[] }) {
  const [sorting, setSorting] = useState<SortingState>([]);

  const table = useReactTable({
    data: cats,
    columns,
    state: { sorting },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  return (
    <>
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                const sorted = header.column.getIsSorted();
                const label = flexRender(header.column.columnDef.header, header.getContext());
                // aria-sort は、いちばん優先される1列にだけ付ける
                const isFirst = sorted && header.column.getSortIndex() === 0;
                return (
                  <th key={header.id} aria-sort={isFirst ? ariaSort[sorted] : undefined}>
                    {header.column.getCanSort() ? (
                      <button type="button" onClick={header.column.getToggleSortingHandler()}>
                        {label}
                        {sorted ? arrows[sorted] : null}
                        {sorted && sorting.length > 1 ? header.column.getSortIndex() + 1 : null}
                      </button>
                    ) : (
                      label
                    )}
                  </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>
      </table>
      <pre id="state">{JSON.stringify(sorting)}</pre>
    </>
  );
}

table.getRowModel().rows には、並べ替えた後の行が入っています。見出しで使った列の関数は、次のとおりです。

関数 使い方
getCanSort() その列が並べ替えられるか。false の列にはボタンを置かない
getToggleSortingHandler() クリックで次の並べ方に切り替える関数を返す。ボタンの onClick に渡す
getIsSorted() 今の並べ方。'asc'(昇順)、'desc'(降順)、false(並べ替えなし)のどれか
getSortIndex() 複数の列で並べたときに、何番目に優先される列か(sorting の配列の中の位置で、0 から数える)

<pre id="state"> は、検証で sorting の中身を見るためだけに置いたものです。検証環境で表示したところ、「ひとこと」の見出しにはボタンがありませんでした。

クリックするたびに、昇順・降順・なしを回る

「名前」の見出しを4回続けてクリックし、そのたびに sorting と並び順を記録しました。

text
 1回目
  sorting: [{"id":"name","desc":false}]
  行: あずき 5 3.5 / そら 3 5.1 / たま 3 4.2 / クロ 8 4.6 / ハチ 1 2.9 / ミケ 5 3.8
 2回目
  sorting: [{"id":"name","desc":true}]
  行: ミケ 5 3.8 / ハチ 1 2.9 / クロ 8 4.6 / たま 3 4.2 / そら 3 5.1 / あずき 5 3.5
 3回目
  sorting: []
  行: たま 3 4.2 / ミケ 5 3.8 / そら 3 5.1 / クロ 8 4.6 / あずき 5 3.5 / ハチ 1 2.9

1回目で昇順、2回目で降順になり、3回目で sorting が空になって、データのままの並びに戻りました。4回目は、また昇順でした。

「年齢」の見出しで同じことをすると、向きが逆になりました。1回目が降順("desc":true)、2回目が昇順、3回目で並べ替えなしです。

text
 1回目
  sorting: [{"id":"age","desc":true}]
  行: クロ 8 4.6 / ミケ 5 3.8 / あずき 5 3.5 / たま 3 4.2 / そら 3 5.1 / ハチ 1 2.9

並べ替えのガイドには、最初の向きは文字列の列なら昇順、数値の列なら降順だと書かれています。npm で配布されている @tanstack/table-core のビルド済みのコードでは、1行目の値が文字列なら昇順、そうでなければ降順を選んでいました。最初の向きを変えたいときは、列か表の設定で sortDescFirst を指定します。並べ替えなしに戻さず、昇順と降順だけを行き来させたいときは、表の設定で enableSortingRemoval: false にすればよいと、ガイドに書かれています(この2つは試していません)。

表の見出しを4回クリックする様子。名前で昇順、名前で降順、年齢で降順、Shift を押しながら体重をクリックして2列で並べる。下の枠に、何回目のクリックか、押した見出し、sorting の中身が出ている
見出しを4回クリックしたときの、行の並びと sorting の変わり方

GIF の下の枠と、カーソルの絵は、撮影用のスクリプトが書き足した表示です。クリックした瞬間に、カーソルにピンクの輪を付け、表の本体をピンクの枠で囲んでいます。枠の中の、押した見出しと sorting の中身も、変わった瞬間だけピンクにしました。

Shift を押しながらクリックすると、複数の列で並べる

getToggleSortingHandler() が返す関数は、Shift キーを押しながらクリックされると、今の並べ替えに列を足します。「年齢」を2回クリックして昇順にしてから、Shift を押しながら「体重」をクリックしました。

text
  sorting: [{"id":"age","desc":false},{"id":"weight","desc":true}]
  行: ハチ 1 2.9 / そら 3 5.1 / たま 3 4.2 / ミケ 5 3.8 / あずき 5 3.5 / クロ 8 4.6

年齢の昇順で並び、年齢が同じ3歳の「そら」と「たま」、5歳の「ミケ」と「あずき」は、体重の重い順になりました。体重は数値の列なので、足したときの向きも降順です。上の GIF の最後のクリックは、年齢を降順にした状態で、Shift を押しながら体重をクリックしたものです。こちらも、年齢が同じ行は体重の重い順になっています。

この状態から、Shift を押さずにクリックしたときの動きは、どの列を押したかで変わりました。最後に足した「体重」を押すと、「年齢」は残ったまま、体重だけが昇順に変わりました。続けて、先に並べていた「年齢」を押すと、「体重」の並べ替えは外れ、年齢だけになりました。

text
# 続けて、Shift なしで「体重」をクリック
  sorting: [{"id":"age","desc":false},{"id":"weight","desc":false}]
# 続けて、Shift なしで「年齢」をクリック
  sorting: [{"id":"age","desc":false}]

配布されているコードでも、Shift なしで押した列が、並べ替えの最後の列なら向きの切り替え、そうでなければ置き換えになっていました。Shift の代わりに使うキーは、表の設定の isMultiSortEvent で変えられると、ガイドに書かれています。

見出しはボタンにして、aria-sort を付ける

見出しのクリックを th ではなく button で受けたのは、キーボードでも操作できるようにするためです。Tab キーで「名前」のボタンにフォーカスし、Enter キー、スペースキーの順に押しました。

text
  フォーカス: 名前
  Enter の後: [{"id":"name","desc":false}]
  Space の後: [{"id":"name","desc":true}]

どちらのキーでも、クリックと同じように並べ替えが切り替わりました。

並べ替えた列の th には、aria-sort を付けています。WAI-ARIA の仕様(編集者草案)では、aria-sort は表の見出しに付けるもので、1つの表で同時に付けるのは1つの見出しだけにするよう書かれています。そのため、複数の列で並べたときは、getSortIndex() が 0 の列(いちばん優先される列)にだけ付けました。「年齢」と「体重」で並べたときの見出しは、次のとおりです。

text
  見出し: 名前[aria-sort=なし] | 年齢 ▲1[aria-sort=ascending] | 体重(kg) ▼2[aria-sort=なし] | ひとこと[aria-sort=なし]

なお、実際のスクリーンリーダーでの読み上げは試していません。

日本語の名前は、Intl.Collator で比べる

最初の「名前」の昇順は、「あずき / そら / たま / クロ / ハチ / ミケ」でした。ひらがなの名前が先に並び、カタカナの名前が後ろにまとまっています。文字の番号の順で比べているためです。

あいうえお順にしたかったので、列の sortingFn に、Intl.Collator('ja') で比べる関数を渡しました。並べ替えのガイドによると、sortingFn の関数は2つの行と列の ID を受け取り、昇順のときの大小を -1、0、1 で返します。降順のことは考えなくてよいとされています。

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

// 名前を、日本語の辞書順(ひらがなとカタカナを区別しない並び)で比べる
const collator = new Intl.Collator('ja');
const byJapaneseName: SortingFn<Cat> = (rowA, rowB, columnId) =>
  collator.compare(rowA.getValue<string>(columnId), rowB.getValue<string>(columnId));

export const columnsJa = [
  columnHelper.accessor('name', { header: '名前', sortingFn: byJapaneseName }),
  ...columns.slice(1),
];

この列の定義で「名前」をクリックすると、次の並びになりました。

text
  行: あずき 5 3.5 / クロ 8 4.6 / そら 3 5.1 / たま 3 4.2 / ハチ 1 2.9 / ミケ 5 3.8

ひらがなとカタカナがまざって、あいうえお順に並びました。

10行以下の表では、数字をまぜた文字列が自然な順にならない

sortingFn を指定しない列では、比べ方が自動で選ばれます。並べ替えのガイドには、alphanumeric(文字列の中の数字を数として比べる)、text、basic などの6つの比べ方があり、既定では列のデータの型から選ばれると書かれています。

ところが、配布されているコードで選び方を見ると、調べているのは11行目からの行(flatRows.slice(10))でした。10行以下の表では、調べる行がなく、文字列の列でも basic(a > b で比べるだけ)になります。

cat1、cat2… のような文字列の列を、Node.js で @tanstack/table-core を直接使って並べ替え、行の数を変えて比べました。

text
行の数 10、sortingFn 指定なし: 使われた比べ方 = basic
  昇順: cat1, cat10, cat2, cat3, cat4, cat5, cat6, cat7, cat8, cat9
行の数 11、sortingFn 指定なし: 使われた比べ方 = alphanumeric
  昇順: cat1, cat2, cat3, cat4, cat5, cat6, cat7, cat8, cat9, cat10, cat11
行の数 10、sortingFn alphanumeric: 使われた比べ方 = alphanumeric
  昇順: cat1, cat2, cat3, cat4, cat5, cat6, cat7, cat8, cat9, cat10

10行のときは cat10 が cat1 のすぐ後に来ました。11行にすると、cat10 は cat9 の後に来ます。sortingFn: 'alphanumeric' と指定すれば、10行でも数の順になりました。行の数でふるまいが変わらないように、文字列の列には比べ方を指定しておくと安心です。

ポイント

並べ替えのできる表は、getSortedRowModel を渡し、見出しのボタンに getToggleSortingHandler() を付けるだけで作れます。日本語の名前や、数字をまぜた文字列の列には、sortingFn で比べ方を指定してください。

​Posted by 86