チェックボックスの全選択がページングで壊れる原因と状態管理の設計(デモ付き)

症状は3つ、原因は1つ

一覧テーブルのヘッダーに置いた「全選択チェックボックス」は、単純に実装すると業務アプリで次の3つの症状を起こします。

症状1:ページを移動したら選択が消える

1ページ目で数件チェックしてから2ページ目へ進み、戻ってくると選択がリセットされている。

症状2:絞り込み後の「全選択」が何を指すか曖昧

フィルタで一部だけ表示したときにヘッダーの全選択を押すと、見えている行だけなのか全件なのかが決まっていない。

症状3:行の追加・削除でヘッダーのチェック状態がズレる

全選択の状態で行を1件足すと、ヘッダーはチェックのままで新しい行だけ未選択になる。件数カウンターも合わなくなる。

症状は3つに見えますが、原因はすべて同じで1つです。選択状態の「正」をDOMのチェックボックス(checked)に置いているせいで、行が入れ替わった瞬間に状態が失われます。この記事では原因を1つに収束させ、対策も1回だけ書きます。症状ごとに原因と対策を繰り返しません。動く正解の最小構成は全選択チェックボックスの事例ページにコピペ可能な形で置いてあるので、先に触ってから読むと設計が掴めます。

ページング付きテーブルで選択が消える失敗デモ

下のデモは、選択状態をDOMの checked だけで持っている「壊れた実装」です。タスク一覧を1ページ5件で3ページに分けています。1ページ目でいくつかチェックしてから、ページャーで2ページ目へ移動し、また1ページ目へ戻ってみてください。チェックがすべて消えているはずです。件数カウンターも「現在のページ」しか数えられません。

壊れた実装(選択をDOMのcheckedだけで持っている)

ページを移動するとtbodyを作り直すため、前のページでチェックした選択は失われます。

現在のページで0件選択中
ID タスク名 担当者 期限 状態

やっていることは「ページを描くたびにtbodyを作り直す」だけです。checkboxの checked はDOM要素にぶら下がった値なので、行を作り直せば一緒に消えます。選択数も、いま画面にある行から数えるしかありません。

// NG: 選択の正体がDOMのcheckedしかない
function renderPage() {
  tbody.textContent = '';                       // ページを描くたびにcheckedごと消える
  pageRows.forEach(function (task) {
    tbody.appendChild(buildRow(task));          // 毎回まっさらな未チェック行になる
  });
}

// 選択数は「いま見えている行」から数える → 他ページの選択は数えられない
var count = tbody.querySelectorAll('.row-check:checked').length;

原因:状態の正をDOMに置いている

3つの症状はどれも、この一点から生まれます。選択という状態の保管場所を、表示のための入れ物であるDOMに兼ねさせていることです。ページングでtbodyを作り直せば選択は消え(症状1)、絞り込みで行を差し替えれば全選択が何を指すか決められず(症状2)、行を足せばDOMには未チェックの行が増えるだけでヘッダーとの整合が崩れます(症状3)。

DOMは「いまどの行を選んでいるか」を映す表示先であって、状態そのものではありません。表示先を作り直すたびに状態が消えるのは、状態を表示先に置いているからです。ここを分ければ、3つの症状はまとめて解決します。

対策:Setで状態を持つ設計と更新関数の集約

対策は2つの原則に集約されます。選択中の行IDを Set に持ち、DOMはそこから描き直すだけにすること。そしてヘッダーの checked / indeterminate・件数カウンター・ボタンの disabled を、1つの関数(updateSelectAllState())にまとめて更新することです。状態が変わるたびにこの関数を呼べば、更新漏れによるズレが起きません。

ページを描くときは、行の checkedSet から復元します。Set は行の描き直しと無関係にメモリ上に残るので、ページを行き来しても選択は生き続けます。以下がコピペで使える中核部分です。

// 選択中の行IDを Set で保持する(DOMのcheckedを状態の正としない)
const selectedIds = new Set();
const PAGE_SIZE = 5;
let currentPage = 1;

// いま表示しているページの行IDを返す
function currentPageIds() {
  const start = (currentPage - 1) * PAGE_SIZE;
  return tasks.slice(start, start + PAGE_SIZE).map(function (t) { return t.id; });
}

// ページを描くたびに、checked は Set から復元する
function renderPage() {
  tbody.textContent = '';
  const start = (currentPage - 1) * PAGE_SIZE;
  tasks.slice(start, start + PAGE_SIZE).forEach(function (task) {
    const tr = buildRow(task);                 // createElement で組み立てる
    const cb = tr.querySelector('.row-check');
    cb.checked = selectedIds.has(task.id);     // 状態はSetが正。DOMは表示するだけ
    tr.classList.toggle('is-selected', selectedIds.has(task.id));
    tbody.appendChild(tr);
  });
  updateSelectAllState();
}

// 行チェックの変更 → Set を更新して状態を再導出
function onRowChange(id, checked) {
  if (checked) selectedIds.add(id);
  else selectedIds.delete(id);
  updateSelectAllState();
}

// ヘッダーの全選択 → 表示中ページの行だけを対象にする
selectAll.addEventListener('change', function () {
  currentPageIds().forEach(function (id) {
    if (selectAll.checked) selectedIds.add(id);
    else selectedIds.delete(id);
  });
  renderPage();                                // Set から描き直す
});

// ヘッダー状態・件数・ボタンを1関数に集約して更新する
function updateSelectAllState() {
  const ids = currentPageIds();
  const onPage = ids.filter(function (id) { return selectedIds.has(id); }).length;

  if (onPage === 0) {
    selectAll.checked = false;
    selectAll.indeterminate = false;
  } else if (onPage === ids.length) {
    selectAll.checked = true;
    selectAll.indeterminate = false;
  } else {
    selectAll.checked = false;
    selectAll.indeterminate = true;            // JSプロパティでのみ設定できる
  }

  const total = selectedIds.size;
  countEl.textContent = total + '件選択中';
  // 表示中ページ以外の選択も生きていることを示す
  otherEl.textContent = (total - onPage) > 0
    ? '(他のページで' + (total - onPage) + '件選択中)' : '';
  clearBtn.disabled = (total === 0);
}

行の生成(buildRow)は createElementtextContent で組み立て、innerHTML に値を結合しません。ユーザー入力やAPI由来の文字列をそのまま結合するとXSSの入り口になるためです。

ページを移動しても選択が維持される修正デモ

下は、失敗デモとまったく同じデータ・同じ見た目のまま、選択の持ち方だけを Set に差し替えたものです。1ページ目でチェックして2ページ目へ進んでも、ツールバーの「他のページで◯件選択中」が選択の合計を示し続けます。1ページ目へ戻ればチェックもそのまま残っています。ヘッダーの全選択は表示中のページだけを対象にするので、各ページで独立してON/OFFできます。

正しい実装(選択中の行IDをSetで保持する)

選択はSetに持つので、ページを移動しても失われません。ヘッダーの全選択は表示中ページのみが対象です。

0件選択中
ID タスク名 担当者 期限 状態

失敗デモとの差はJSの1点、状態を checked ではなく Set に持つかどうかだけです。ページングを外した最小構成(1ページ完結の全選択)を確認したいときは、全選択チェックボックスの事例ページにヘッダーの中間状態・件数カウンター・行ハイライトを揃えた実装を置いています。

全選択の範囲の設計判断(表示中ページか全件か)

症状2、絞り込み後や複数ページでの「全選択」が何を指すかは、実装の前に決める設計判断です。結論として、デフォルトは「全選択=表示中のページのみ」を推奨します。一覧の選択はたいてい一括削除・一括ステータス変更といった破壊的な操作につながるため、いま画面に見えていない行までヘッダー1クリックで巻き込むのは事故のもとだからです。フィルタで20件に絞ったつもりが、裏で500件を選んでいた、という削除は取り返しがつきません。

それでも「該当する全件をまとめて操作したい」需要はあります。その場合は、Gmailが採用している方式が扱いやすいです。ヘッダーの全選択を押すとまず表示中ページだけが選択され、その上に「このページの5件を選択しました。該当する全12件を選択」というバナーを別に出して、全件選択を明示的な2アクション目にします。既定を安全側(ページ内)に置き、全件は意図的な操作としてバナーで受けるのが、業務アプリでは無難です。このバナーは本文の設計として紹介するにとどめ、デモでは扱いません。

indeterminateの実装(JSプロパティでのみ設定できる)

ヘッダーを「全件でも0件でもない、一部だけ選択」の中間状態にするのが indeterminate です。ここで一度は誰もがはまるのが、HTMLの属性では設定できないという点です。

<input type="checkbox" indeterminate> と書いても効きません。indeterminate はコンテンツ属性ではなくIDL属性なので、el.indeterminate = true というJavaScriptのプロパティでのみ設定できます。

もう1つ押さえておきたいのが、indeterminatechecked独立したプロパティだという点です。中間状態にしても checked の値は別に保持されており、見た目が斜線やハイフンになるだけで、送信値やクリック時の挙動は checked が支配します。そのため中間状態にするときは、下のように checked = falseindeterminate = true を必ずセットで指定します。

// 一部だけ選択 → 中間状態にする(2つのプロパティを両方セットする)
selectAll.checked = false;
selectAll.indeterminate = true;

// 全件選択・0件に戻すときは indeterminate を必ず false へ
selectAll.checked = true;
selectAll.indeterminate = false;   // 消し忘れると中間状態が残る

この checked / indeterminate の切り替えは、前掲の updateSelectAllState() の中で一括して行っています。選択が変わるたびにこの1関数を通せば、中間状態の消し忘れが起きません。

エッジケース一覧

全選択UIで「この場合はどうなるか」を、6つの操作について整理します。いずれも selectedIds(Set)を状態の正とし、更新を updateSelectAllState() に集約していれば、素直に処理できます。

操作 挙動・設計 実装のポイント
ページ移動 選択を維持する 状態はSetにあるので、ページを描き直しても各行の checked をSetから復元するだけ。DOMを作り直しても選択は消えない
フィルタ変更 維持か破棄かを決める 一括削除など破壊的操作があるなら、非表示になった行の選択が残るのは危険。全破棄するか、フィルタ結果に含まれる選択だけを残す。判断基準は「見えない行を巻き込む操作が後段にあるか」
全選択の対象 表示中ページのみを推奨 見えていない行まで巻き込む事故を防ぐ。全件を対象にしたいときはGmail方式の「全◯件を選択」バナーを別アクションとして出す
行追加 ヘッダーがindeterminateに落ちる 全選択中でも追加された行は未選択。表示中ページに新しい行が入れば updateSelectAllState() が自動で中間状態にする。集約関数を通していれば手当て不要
行削除 selectedIds からも必ず消す DOMの行だけ消してSetにIDが残ると、件数カウンターが実在しない行を数え続ける(残留IDの罠)。行データとSetの両方から削除する
ソート 影響なし 選択はidで持つので、並び順が変わっても選択は保たれる。描き直し後に checked をSetから復元するだけ

まとめ

全選択チェックボックスがページング移動や絞り込みで壊れるときは、症状が3つに見えても原因は1つです。選択状態の正をDOMの checked ではなく Set に持ち替えること、そしてヘッダー状態・件数・ボタンの更新を updateSelectAllState() 1関数に集約すること。この2点で3つの症状はまとめて解決します。

選択はSetに持つ → 描画時はSetからcheckedを復元する → ヘッダーのchecked/indeterminate・件数・ボタンは1関数で更新する。全選択の既定は「表示中ページのみ」にして、全件は明示的な2アクション目で受ける。

ページングなしの最小構成はテーブル(Table)— 全選択チェックボックスで動かせます。この設計を土台にした一括操作の事例も用意しています。ExcelライクなShift+クリックの範囲選択選択行の一括削除(確認モーダルと取り消しトースト付き)、選択行の一括ステータス変更と、いずれも選択状態をSetで持つ同じ設計で組んでいます。

関連するUI事例

この記事の修正コードに対応する、コピペで動くテーブル系の事例です。