はじめに (対象読者・この記事でわかること)

この記事は、Vue.js を使ってフロントエンド開発を行っているエンジニアを対象としています。特に、フォーム上で「Select Box」をユーザーが必要なだけ追加できるインターフェースを実装した経験がある方、もしくはこれから取り組もうとしている方に最適です。
本記事を読むことで、以下のことができるようになります。

  • 動的に生成される複数の select 要素に対して、共通の検索フィールドでリアルタイムに絞り込みができる仕組みを構築できる
  • Vue のリアクティブ特性と computed / watch を活用したパフォーマンスの高いフィルタリングロジックを実装できる
  • 無限に追加された select ボックスが増えても、UX が低下しないようにデバウンスやキャッシュを取り入れる方法が理解できる

この記事を書いたきっかけは、社内プロジェクトで「商品カテゴリを階層的に選択できる UI」を作る際、ユーザーが必要に応じて select を増やすと同時に検索できない不便さを指摘されたことです。その課題を解決すべく、汎用的かつ拡張しやすいフィルタリングコンポーネントをまとめました。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • HTML/CSS の基本的な知識
  • JavaScript(ES6 以降)の基礎
  • Vue 3(Composition API)もしくは Vue 2(Options API)の基本的な使い方
  • npm / Yarn でパッケージをインストールできる環境

概要・背景

Web フォームで「複数の選択肢を段階的に追加」できる UI は、商品検索やタグ付け、権限設定などさまざまなシチュエーションで求められます。Vue.js では v-for とコンポーネント分割で簡単に無限に select を増やすことができますが、検索機能が無いとユーザーは長いリストの中から目的の項目を探すのに時間がかかります。
そこで本記事では、次の 2 つの課題を同時に解決する方法を提示します。

  1. リアルタイムフィルタリング
    入力欄に文字を入れるたびに、全ての select ボックスのオプションが絞り込まれます。
  2. パフォーマンス最適化
    無限に増える select が 20 個、30 個を超えても UI が固まらないように、デバウンスとキャッシュを組み合わせます。

この実装は「1 つの検索フィールドで全 select を制御」するだけでなく、個別に検索させたい場合でも拡張しやすい構造になっています。Vue の reactivity と computed プロパティをフル活用し、コード量を最小限に保ちながら高い応答性を実現します。

具体的な手順や実装方法

以下の例は Vue 3 + Composition API を前提にしていますが、Options API に置き換えることも容易です。まずはプロジェクトに必要なパッケージをインストールし、基本的なコンポーネント構成を作ります。

ステップ 1: プロジェクトセットアップと依存パッケージ

Bash
# Vue CLI または Vite でプロジェクト作成 npm init vite@latest vue-select-filter -- --template vue cd vue-select-filter npm install # lodash.debounce をインストール(デバウンス用) npm install lodash.debounce

main.js(または main.ts)で Vue アプリを起動したら、次にコンポーネントを作成します。

ステップ 2: データ構造の設計

Js
// src/components/SelectList.vue <script setup> import { ref, computed, watch } from 'vue'; import debounce from 'lodash.debounce'; // ① 全候補データ(サーバーから取得した想定) const allOptions = ref([ { id: 1, label: 'Apple' }, { id: 2, label: 'Banana' }, { id: 3, label: 'Cherry' }, // … 100 件以上のデータがあると想定 ]); // ② 動的に追加される select の数と選択状態 const selects = ref([ { id: 0, selected: null }, ]); // ③ フィルタ文字列(全 select 共通) const filterText = ref(''); // ④ デバウンス処理(200ms 待機) const debouncedFilter = debounce((val) => { filterText.value = val; }, 200); // ⑤ フィルタ結果を memo 化 const filteredOptions = computed(() => { if (!filterText.value) return allOptions.value; const lower = filterText.value.toLowerCase(); return allOptions.value.filter(opt => opt.label.toLowerCase().includes(lower)); }); </script>

上記では、全候補データ現在表示中の select 群ref で管理しています。filterText が変化したときは debouncedFilter に委譲し、頻繁な再計算を防ぎます。

ステップ 3: テンプレートと動的追加ロジック

Html
<template> <div class="filter-container"> <input type="text" placeholder="検索キーワード..." @input="e => debouncedFilter(e.target.value)" class="filter-input" /> </div> <div class="select-list"> <div v-for="(sel, index) in selects" :key="sel.id" class="select-item"> <select v-model="sel.selected"> <option :value="null" disabled>-- 選択してください --</option> <option v-for="opt in filteredOptions" :key="opt.id" :value="opt.id" >{{ opt.label }}</option> </select> <!-- 追加ボタンは最後の select のみ表示 --> <button v-if="index === selects.length - 1" @click="addSelect" class="add-btn" >+ 追加</button> </div> </div> </template> <script setup> // 前述のスクリプトブロックに続くロジック const addSelect = () => { const newId = selects.value.length ? Math.max(...selects.value.map(s => s.id)) + 1 : 0; selects.value.push({ id: newId, selected: null }); }; </script> <style scoped> .filter-container { margin-bottom: 1rem; } .filter-input { width: 100%; padding: 0.5rem; } .select-list { display: flex; flex-direction: column; gap: 0.5rem; } .select-item { display: flex; align-items: center; gap: 0.5rem; } .add-btn { background: #42b983; color: white; border: none; padding: 0.3rem 0.6rem; cursor: pointer; } </style>

ポイントは以下の通りです。

項目 説明
共通フィルタ入力 1つの <input>filterText を更新し、全 select のオプションに即時反映
デバウンス lodash.debounce により、ユーザーが入力し続けても 200ms 以内に再計算されない
computed フィルタ filteredOptionsfilterText 依存で再計算され、リアクティブに各 <select> にバインド
動的追加 addSelectselects 配列にオブジェクトを push、v-for が自動で UI 生成

ステップ 4: 個別検索(Optional)とキャッシュ戦略

上記の実装は「全 select が同じ検索文字列で絞り込まれる」構成です。もし「各 select に独立した検索ボックス」を持たせたい場合は、selects 配列に filter プロパティを追加し、computedselect ごとに作ります。

Js
// select オブジェクト例 { id: 0, selected: null, filter: '' }

そして、各 select の computed を次のように定義:

Js
const getFilteredOptions = (filter) => { if (!filter) return allOptions.value; const lower = filter.toLowerCase(); return allOptions.value.filter(opt => opt.label.toLowerCase().includes(lower)); };

この場合、キャッシュは Map で管理すると便利です。

Js
const cache = new Map(); // key: filter文字列, value: filtered配列 function cachedFilter(filter) { if (cache.has(filter)) return cache.get(filter); const result = getFilteredOptions(filter); cache.set(filter, result); return result; }

ハマった点やエラー解決

発生した問題 原因 解決策
watchfilterText が更新されても UI が更新されない watch の第2引数を省略し、即時実行しなかった watch(filterText, (newVal) => { /* 更新処理 */ }, { immediate: true }) を追加
多数の select が同時に再描画され、フレーム落ちした filteredOptions が毎回全配列を走査したため debouncecomputed の組み合わせで再計算回数を削減、さらに v-show で未表示の <select> を一時的に非表示に
key が重複して Vue が再利用してしまい、選択状態が混在 selectsid が重複した addSelectnewIdMath.max + 1 で生成し、一意性を保証
lodash.debounce が TypeScript で型エラーになる 型定義が抜けていた npm i -D @types/lodash.debounce または import debounce from 'lodash.debounce' のまま any キャスト

解決策まとめ

  • デバウンスで無駄な再計算を防ぎ、UI の応答性を確保。
  • computed + cacheで同一検索文字列の結果を再利用し、CPU コストを削減。
  • 一意キーを必ず付与し、Vue の再利用アルゴリズムが期待通りに働くようにする。
  • watch の immediate オプションで初期状態でも正しく反映させる。

まとめ

本記事では、Vue.js で「無限に追加できる select box」に対して、リアルタイムかつ高速なフィルタリング機能を実装する具体的な手順を解説しました。

  • リアルタイム検索:単一入力で全 select のオプションを絞り込み
  • パフォーマンス最適化:lodash.debounce と computed による再計算抑制、結果キャッシュの活用
  • 拡張性:個別検索やキャッシュ戦略を組み込みやすいコンポーネント設計

この手法を取り入れることで、ユーザーは大量データの中から欲しい項目を瞬時に見つけられ、開発者はコードベースをシンプルに保ちながら高い UX を提供できます。次回は「サーバーサイドでのオプション取得とページネーション」を組み合わせた、さらにスケーラブルな実装例を紹介する予定です。

参考資料