連動プルダウンで前の選択値が残る・リセットされない原因と直し方

症状:親を変えても子の選択値・選択肢が前のまま残る

都道府県を選ぶと市区町村が切り替わる、といった連動プルダウン(カスケードセレクト)で、親を別の値に変えたのに子のプルダウンが前の状態のまま残ってしまう。市区町村の選択肢が消えずに積み上がる、前に選んだ値がそのまま送信される、あるいは編集画面を開くと子だけ空になる。これらは連動プルダウンで最も多いつまずきです。

原因は「子の選択肢をどう作り直すか」と「保存値をどの順番で復元するか」に集約されます。この記事では、よくある原因を先に一覧で示し、失敗する様子を動くデモで確認しながら直していきます。新規入力で正しく連動する完成形は連動プルダウンの事例ページにコピペ可能な形で置いているので、まず動く正解を触ってから読むと理解が早いです。

前の値が残る・リセットされない主な原因

実務で遭遇する連動プルダウンの不具合は、ほぼ次の4つに収まります。上から順に確認してください。

原因1:子の選択肢を作り直さず追加している

親の変更時に、古い option を消さずに appendChild で足しているため、前の市区町村が消えずに積み上がって重複する。

原因2:選択肢は変えたが選択値(value)を戻していない

子の option は入れ替えたのに select.value を初期状態へ戻していないため、前に選んだ古い値がそのまま残って送信される。

原因3:親を「選択してください」に戻したときの分岐がない

親を未選択に戻したケースの処理が抜けていて、子の選択肢と値、disabled 状態が前のまま残る。

原因4:編集画面で保存値を復元する順序が違う

親に値をセットしただけでは子の選択肢は作られないため、子の選択肢が無いまま保存値をセットして、市区町村だけ空になる。

原因1から3までは新規入力の連動そのもの、原因4は編集画面で保存済みデータを開いたときに起きます。以下、失敗デモと修正コードで順に見ていきます。

失敗例:親を変えると子の選択肢が積み上がる

下のデモは、原因1と原因2をわざと再現した「壊れた実装」です。都道府県を選ぶと市区町村が出ますが、別の都道府県に切り替えても前の市区町村が消えずに残り、選択肢がどんどん積み上がっていきます。実際に都道府県を何度か切り替えてみてください。

壊れた実装(消さずに追加している)

都道府県を切り替えるたびに、前の市区町村が消えずに市区町村プルダウンへ積み上がります。

// NG: 前の option を消さずに追加している → 前の市区町村が残って積み上がる
prefSelect.addEventListener('change', function () {
  PREF[prefSelect.value].cities.forEach(function (c) {
    var opt = document.createElement('option');
    opt.value = c.code;
    opt.textContent = c.name;
    citySelect.appendChild(opt); // 追加するだけ。古い option が残ったまま
  });
});

正しい連動の完成形は連動プルダウンの事例ページで動かせます。ここでは、この失敗をどう直すかをコードで見ていきます。

直し方① 子の選択肢は毎回まっさらにして作り直す

原因1と原因2は、同じ一手でまとめて解決します。親が変わったら、子の optioninnerHTML = ''いったん全部消してから作り直すことです。全消しした時点で前の選択値も一緒に破棄されるので、「値のリセット漏れ」(原因2)も自動的に消えます。option を1個ずつ差分更新するより、まるごと作り直すほうが状態のズレが起きません。

// OK: 毎回まっさらにしてから作り直す。前の選択値もここで破棄される
prefSelect.addEventListener('change', function () {
  citySelect.innerHTML = '';                       // まず全部消す(前の値もここで消える)
  citySelect.appendChild(makeOption('', '選択してください'));
  PREF[prefSelect.value].cities.forEach(function (c) {
    citySelect.appendChild(makeOption(c.code, c.name));
  });
});

function makeOption(value, text) {
  var opt = document.createElement('option');
  opt.value = value;
  opt.textContent = text;                           // innerHTML への文字列結合は使わない
  return opt;
}

選択肢の再構築は createElementtextContent で行います。ユーザー入力やAPI由来の文字列を innerHTML に結合するとXSSの入り口になるため、要素として組み立てるのが安全です。

直し方② 親を未選択に戻したら子も初期状態へ戻す

もう1つ抜けやすいのが、親を「選択してください」に戻したとき(原因3)です。この分岐がないと、親を空にしても子の選択肢と値が前のまま残ります。親が未選択なら、子はプレースホルダだけにして disabled に戻すのが素直です。

prefSelect.addEventListener('change', function () {
  citySelect.innerHTML = '';
  if (!prefSelect.value) {                          // 親が「選択してください」に戻ったとき
    citySelect.appendChild(makeOption('', '(都道府県を選択)'));
    citySelect.disabled = true;                     // 子を初期状態へ戻す
    return;
  }
  citySelect.disabled = false;
  citySelect.appendChild(makeOption('', '選択してください'));
  PREF[prefSelect.value].cities.forEach(function (c) {
    citySelect.appendChild(makeOption(c.code, c.name));
  });
});

ここまでで新規入力の連動は正しく動きます。都道府県5〜8件を絞ったJS内オブジェクトで持つ実装から、市区町村を静的JSONやAPIで取得する実装まで、コピペで動く完成形は連動プルダウンの事例ページにまとめてあります。この記事の残りは、業務アプリで一番ハマる「編集画面での復元」に絞ります。

編集画面で保存値から連動プルダウンを復元する

新規入力は直せても、保存済みデータを開く編集画面でつまずくケースが非常に多いです。DBに保存した「東京都・渋谷区」を開いたのに、都道府県は入るのに市区町村だけ空になる。これは値をセットする順序の問題です。

鍵になるのは、select.value = xchange イベントを発火しないという点です。画面操作なら親を選んだ瞬間に change が走って子が作られますが、プログラムで value を代入しても連動処理は動きません。そのため子の選択肢が無いまま子の保存値を入れることになり、対応する option が存在せず無視されて空になります。

下のデモで確かめてください。保存レコードのボタンを押すと編集画面を開いたつもりで復元します。「ダメな順序」にチェックを入れると、親だけ入って市区町村が空になる失敗を再現できます。

保存レコードを開いて復元する

保存レコードのボタンを押すと、保存された都道府県と市区町村を復元します。

正しい順序は「親の値をセット → 子の選択肢を作り直す → それから子の値をセット」です。子を作り直す処理は、新規入力の連動で使っているものをそのまま関数として呼び出せば、実装が二重になりません。

// 保存済みデータ(DBから取得した値)
var saved = { pref: '13', city: '13113' }; // 東京都 / 渋谷区

// NG: 親に値を入れても change は発火しない → 子の option が無いまま値を入れて空になる
prefSelect.value = saved.pref;
citySelect.value = saved.city;      // 対応する option が存在せず無視される

// OK: 親の値 → 子の選択肢を作り直す → それから子の値、の順にする
prefSelect.value = saved.pref;
buildCityOptions(saved.pref);       // change ハンドラと同じ再構築処理を関数化して手動で呼ぶ
citySelect.value = saved.city;      // ここで初めて選択できる

どうしても既存の change ハンドラをそのまま使い回したい場合は、prefSelect.dispatchEvent(new Event('change')) で連動を手動発火させてから子の値をセットする方法もあります。ただし、再構築処理を独立した関数に切り出して復元時にも呼ぶほうが、依存関係が読みやすく安全です。3段以上の連動(都道府県→市区町村→地区)でも、この「親から順にセットして、その都度下位を作り直す」流れは同じです。

APIで子の選択肢を取得する場合の非同期の注意

市区町村をAPIから fetch で取得する構成では、もう1種類の「前の値が残る」が起きます。親を素早く切り替えると、先に投げたリクエストの応答が後から返ってきて、新しい親に対応しない古い選択肢で上書きしてしまう競合(レースコンディション)です。

対策は、発火時点の親の値を覚えておき、応答が返ったときに今の親と一致するときだけ描画することです。一致しなければ、その応答は古いものとして捨てます。

var currentPref = '';

prefSelect.addEventListener('change', function () {
  var requested = prefSelect.value;
  currentPref = requested;
  citySelect.innerHTML = '';
  citySelect.appendChild(makeOption('', '読み込み中...'));

  fetch('/api/cities?pref=' + encodeURIComponent(requested))
    .then(function (res) { return res.json(); })
    .then(function (cities) {
      if (requested !== currentPref) return;        // 親が変わっていたら古い応答は捨てる
      renderCities(cities);
    });
});

選択肢の取得元をJS内オブジェクト、静的JSON、APIのどれにするかの判断は事例ページの実装メモにまとめています。同期実装なら競合は起きないので、まずは前半の原因1から4を潰すのが先です。

まとめ

連動プルダウンで前の値が残る・リセットされないときは、子の選択肢を「差分で直そう」とせず、親が変わるたびにまるごと作り直すのが基本です。確認する順番は次のとおりです。

子を innerHTML='' で全消ししてから作り直しているか → 親を未選択に戻したときの分岐があるか → 編集画面では「親の値→子を作り直す→子の値」の順で復元しているか。

特に編集画面での復元は、select.value = xchange を発火しないという一点を押さえるだけで、市区町村が空になる事故はほぼ防げます。新規入力から編集画面の復元まで通した実装は、下の関連事例で動かしながら確認してください。

関連するUI事例

この記事の修正コードに対応する、コピペで動く連動プルダウンの事例です。新規入力の連動と選択肢データの持ち方は事例ページで確認できます。