家計簿アプリを作る #53:一覧の全選択チェックボックス

家計簿アプリ作成シリーズの第53回です。前回実装した一括削除機能に、テーブルヘッダの「全選択」チェックボックスを追加し、1クリックで表示中の全件を選択/解除できるようにします。

実装方針

全選択チェックボックスをONにしたら現在表示中の data.transactions の全IDを selectedTransactionIds にセットし、OFFなら空にします。逆に、個別チェックボックスの操作で一部だけ選択された状態になったら、全選択チェックボックス側の見た目もそれに追従させたいという要件がありました。

function handleSelectAll(event: Event) {
  const target = event.target as HTMLInputElement;
  selectedTransactionIds = target.checked
    ? data.transactions.map((t) => t.id)
    : [];
}

ハマりポイント:$effectでDOMを直接操作しようとして失敗する

「個別のチェックを外したら全選択チェックボックスも自動的に外れる」という挙動を、最初は $effect の中で document.querySelector を使い、DOMを直接書き換える方法で実装しようとしました。

// ❌ 動かない
$effect(() => {
  if (selectedTransactionIds.length === data.transactions.length) {
    const selectAllCheckbox = document.querySelector(
      'input[type="checkbox"][onchange="handleSelectAll(event)"]',
    ) as HTMLInputElement;
    if (selectAllCheckbox) selectAllCheckbox.checked = true;
  }
  // ...
});

このセレクタは常にヒットせず、何も起きませんでした。原因は、Svelteの onchange={handleSelectAll} はイベントリスナーとして内部的に登録されるだけで、onchange="handleSelectAll(event)" という文字列属性としてDOMには出力されないためです。

そもそもSvelteでDOMを直接 querySelector で探して操作するのはアンチパターンで、状態は極力データ($state/$derived)側で管理すべきでした。全選択チェックボックスの状態も、実は selectedTransactionIds から計算できる値なので、$derived で導出する形に直しました。

// ✅ $derivedで導出する($effect・DOM操作は不要)
let isAllSelected = $derived(
  data.transactions.length > 0 &&
  selectedTransactionIds.length === data.transactions.length,
);
<Checkbox checked={isAllSelected} onchange={handleSelectAll} class="focus:ring-0" />

$effect + DOM操作というアプローチ自体が不要で、素直に $derived を使えば、個別チェックボックスの増減に応じて全選択チェックボックスの見た目も自動的に追従するようになりました。

全選択の対象範囲について

現在の実装では、全選択は「現在のページに表示されている取引」のみが対象です。全ページ横断で全選択したい場合は、選択状態をページ切り替え時にも保持し、かつ全ページ分のIDを事前に把握する仕組みが必要になり複雑さが増すため、今回は「表示中のページ内で全選択」というシンプルな仕様に留めています。

まとめ

ポイント 内容
全選択チェックボックスの状態 $effectでDOM操作せず、$derivedで選択件数から導出する
onchange属性のDOM出力 Svelteのイベントハンドラは属性としてDOMに出力されないためquerySelectorでは拾えない
全選択の対象範囲 現ページ内のみに限定し、複雑な状態保持を避ける
← トップページに戻る