Reactで、表の行をチェックボックスでまとめて選ぶ方法
表の行をチェックボックスでまとめて選ぶには、選んだ行の id を Set に入れて state で持ち、各行のチェックボックスの checked をそこから決めます。見出しのチェックボックスは「全部選択」「一部だけ選択」「何も選んでいない」の3つの状態を、その Set と行の一覧から計算して表示します。
検証では、6行のファイル一覧の表を作り、1行ずつの選択、見出しでの全選択と全解除、Shift を押したクリックでの範囲選択、選んだ行の削除を順に確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。
Firefox と Safari、スマートフォンのタッチ操作、スクリーンリーダーでの実際の読み上げ、ページ分けや絞り込みのある表、TanStack Table などのライブラリの行選択の機能は試していません。

選んだ行は、id を入れた Set で持つ
選んだ行は、行の id を入れた Set を state にして覚えます。
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
行の番号(何行目か)で覚えないのは、行を削除すると、そのあとの行の番号がずれるからです。id で覚えておけば、行が減っても、選んだ行や Shift の起点を取り違えません(削除をはさんだときの起点の動きは、Shift で範囲を選ぶ節で紹介します)。
選択を変えるときは、今の Set に add や delete をするのではなく、新しい Set を作って setSelected に渡します。useState のリファレンスにある、state を更新したのに画面が変わらないときの説明のとおり、React は新しい値と前の値を Object.is で比べ、同じなら更新を無視します。今の Set を書き換えて渡すと同じものとみなされるので、画面が変わりません。コードでは、setSelected に渡す関数の中で new Set(prev) でコピーを作ってから、コピーのほうに add や delete をしています。
表のコンポーネントの全体
検証に使った FileTable の全体です。行の一覧も state にしているのは、あとで選んだ行を削除するためです。
import { useEffect, useRef, useState } from 'react';
export type FileRow = { id: string; name: string; size: string };
export function FileTable({ initialRows }: { initialRows: FileRow[] }) {
const [rows, setRows] = useState(initialRows);
// 選んだ行は、並び順ではなく id で覚える
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
// Shift を押してクリックしたときの起点
const anchorId = useRef<string | null>(null);
const allChecked = rows.length > 0 && rows.every((r) => selected.has(r.id));
const someChecked = rows.some((r) => selected.has(r.id));
// indeterminate は HTML の属性がないので、DOM のプロパティに直接入れる
const headerRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (headerRef.current) {
headerRef.current.indeterminate = someChecked && !allChecked;
}
}, [someChecked, allChecked]);
function toggleAll() {
setSelected(allChecked ? new Set() : new Set(rows.map((r) => r.id)));
}
function toggleRow(e: React.ChangeEvent<HTMLInputElement>, id: string) {
const checked = e.target.checked;
const shift = e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey;
const from = rows.findIndex((r) => r.id === anchorId.current);
const to = rows.findIndex((r) => r.id === id);
// Shift なら起点からこの行までを、この行と同じ状態にそろえる
const ids =
shift && from !== -1
? rows.slice(Math.min(from, to), Math.max(from, to) + 1).map((r) => r.id)
: [id];
setSelected((prev) => {
const next = new Set(prev);
for (const x of ids) {
if (checked) next.add(x);
else next.delete(x);
}
return next;
});
anchorId.current = id;
}
function deleteSelected() {
setRows((prev) => prev.filter((r) => !selected.has(r.id)));
setSelected(new Set());
}
return (
<>
<p>
{selected.size}件を選択中{' '}
<button type="button" disabled={selected.size === 0} onClick={deleteSelected}>
選んだ行を削除
</button>
</p>
<table>
<thead>
<tr>
<th>
<input
type="checkbox"
ref={headerRef}
checked={allChecked}
onChange={toggleAll}
aria-label="すべての行を選択"
/>
</th>
<th>ファイル名</th>
<th>サイズ</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className={selected.has(r.id) ? 'selected' : undefined}>
<td>
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={(e) => toggleRow(e, r.id)}
aria-label={`${r.name} を選択`}
/>
</td>
<td>{r.name}</td>
<td>{r.size}</td>
</tr>
))}
</tbody>
</table>
</>
);
}
チェックボックスには checked と onChange の両方を渡しています。input のリファレンスのとおり、checked を渡したチェックボックスは React が状態を決める入力欄(制御されたコンポーネント)になり、値を更新する onChange が必要です。
各行のチェックボックスには、aria-label で「photo.png を選択」のような名前を付けました。この表では、チェックボックスに label 要素はありませんが、隣のセルにファイル名が見えています。MDN の aria-label の説明では、名前にできる文字が画面に見えているときは、aria-labelledby でその文字を指す方法を勧めています。ここでは、ファイル名だけでなく「〜を選択」まで含めた名前にしたかったので、aria-label を使いました。検証では、Playwright の getByRole('checkbox', { name: 'photo.png を選択' }) でこの名前を使って各行のチェックボックスを探し、操作できました。
見出しのチェックボックスの3つの状態
見出しのチェックボックスは、表示している行と Set から2つの値を計算して決めます。全部の行が選ばれていれば allChecked、1行でも選ばれていれば someChecked が true です。チェックを付けるのは allChecked のとき、横線の「一部だけ選択」にするのは、someChecked が true で allChecked が false のときです。
この横線の状態は、チェックボックスの indeterminate で表します。MDN のチェックボックスの説明にあるとおり、indeterminate は JavaScript で DOM のプロパティに入れるもので、HTML の属性では指定できません。そこで useRef で見出しの input を受け取り、useEffect の中でプロパティに入れています。useRef のリファレンスにある DOM を操作する方法と同じ形です。
JSX に indeterminate={true} と書いても、横線にはなりません。検証環境で比べると、@types/react の input の props に indeterminate がないため、tsc で型エラーになりました。型エラーを無視してビルドすると、本番用のビルドではコンソールに何も出ず、input 要素に indeterminate の属性も付きませんでした。開発用のビルドでは、コンソールに次のエラーが出ました。どちらの場合も、要素の indeterminate プロパティは false のままでした。
[console.error] Received `true` for a non-boolean attribute `indeterminate`.
indeterminate プロパティ: false
1行だけ選んだときは、見出しのチェックボックスに横線が出ました。このとき Playwright でアクセシビリティの情報を取り出すと、checked=mixed になっていました。
--- photo.png をクリック
header: checked=false indeterminate=true
checked: photo.png
- checkbox "すべての行を選択" [checked=mixed]
横線の状態で見出しをクリックすると、allChecked が false なので toggleAll は全部の行を選びます。もう一度クリックすると、全部の行の選択が外れました。
--- 見出しのチェックボックスをクリック(一部だけ選択中から)
header: checked=true indeterminate=false
checked: report.pdf, photo.png, memo.txt, slides.pdf, data.csv, logo.svg
--- 見出しのチェックボックスをもう一度クリック
header: checked=false indeterminate=false
checked: (なし)
Shift を押したクリックで、範囲をまとめて選ぶ
1行ずつのクリックに加えて、Shift を押しながらクリックすると、前にクリックした行からその行までをまとめて選べるようにしました。toggleRow は、前にクリックした行の id を anchorId に覚えておき、Shift が押されていれば、その行から今の行までの id をまとめて追加します。外したときは、まとめて外します。
Shift が押されたかどうかは、onChange が受け取るイベント(ChangeEvent)からは直接読めません。React のイベントオブジェクトの説明にあるとおり、e.nativeEvent から元のブラウザのイベントを読めます。ただし、React のイベントにどのブラウザのイベントが対応するかは公開 API の一部ではなく、今後変わる可能性があるとも書かれています。そのためコードでは、MouseEvent であることを instanceof で確かめてから shiftKey を読んでいます。
検証環境(Chromium 153)では、チェックボックスを押したときにブラウザで起きた click イベントは PointerEvent でした。出力の lastClick は、検証用のスクリプトが document でこの click イベントを受け取り、イベントの種類と shiftKey を記録したものです。MDN の PointerEvent の説明のとおり、PointerEvent は MouseEvent を継承しているので、instanceof MouseEvent の確認を通ります。実際に、photo.png をクリックしたあと、Shift を押して data.csv をクリックすると、間の2行を含む4行が選ばれました。
--- Shift を押して data.csv をクリック
checked: photo.png, memo.txt, slides.pdf, data.csv
lastClick: PointerEvent shiftKey=true
続けて、Shift を押してチェック済みの memo.txt をクリックすると、data.csv から memo.txt までの3行の選択が外れ、photo.png だけが残りました。
キーボードでも同じように動きました。report.pdf にフォーカスして Space を押し、次に memo.txt にフォーカスして Shift と Space を押すと、間の photo.png を含めて3行が選ばれました。このときも click イベントは PointerEvent で、shiftKey は true でした。
--- memo.txt にフォーカスして Shift+Space
checked: report.pdf, photo.png, memo.txt
lastClick: PointerEvent shiftKey=true
起点は、行の番号ではなく id で覚える
Shift を押したクリックの起点も、行の番号ではなく id で覚えています。クリックのたびに、行の一覧の中で起点の id が何番目かを探して範囲を決めます。こうしておくと、次の節で説明する「選んだ行を削除」で行が減っても、起点がほかの行にずれません。削除をはさんだときの動きを、新しく開いたページで2通り確かめました。
1つ目は、photo.png と slides.pdf を選び、data.csv を2回クリックして(2回目で選択を外し)、起点を data.csv にしてから削除した場合です。そのあと Shift を押して report.pdf をクリックすると、report.pdf から data.csv までの3行が選ばれました。削除で data.csv が5行目から3行目に移っても、起点は data.csv のままです。
2つ目は、photo.png、slides.pdf の順にクリックし、起点の slides.pdf ごと削除した場合です。そのあと Shift を押して logo.svg をクリックすると、logo.svg だけが選ばれました。起点の行がもうないので findIndex が -1 を返し、1行だけの選択として扱われています。
--- photo.png と slides.pdf を選び、data.csv を2回クリック(起点は data.csv): photo.png, slides.pdf
--- 削除のあと、Shift を押して report.pdf をクリック: report.pdf, memo.txt, data.csv
--- photo.png、slides.pdf の順にクリック(起点は slides.pdf): photo.png, slides.pdf
--- 削除のあと(起点の slides.pdf はもうない)、Shift を押して logo.svg をクリック: logo.svg
選んだ行を削除したら、選択も空にする
「選んだ行を削除」のボタンは、Set が空のときは disabled にしています。押されたら、選ばれた id の行を一覧から除き、選択も空の Set に戻します。コードの上では、選択を残したままにすると、もうない行の id が Set に残り、件数の表示(selected.size)が実際の行と合わなくなります。
photo.png と slides.pdf を選んで削除すると、行は4つになり、件数は0件、ボタンは押せない状態に戻りました。
--- 「選んだ行を削除」をクリック
count: 0件を選択中 選んだ行を削除
rows: 4
button: disabled
続けて report.pdf をクリックしてから、Shift を押して logo.svg をクリックすると、残った4行がすべて選ばれ、見出しのチェックボックスにもチェックが付きました。
--- 削除のあと、report.pdf をクリックし、Shift を押して logo.svg をクリック
header: checked=true indeterminate=false
checked: report.pdf, memo.txt, data.csv, logo.svg
見出しのチェックボックスの状態は、state として別に持たず、行の一覧と選んだ行の Set から毎回計算しています。そのため、削除で行が減ったあとでも、範囲で選んだ結果として残りの行が全部そろえば、見出しにチェックが付きます。





