Vue 3 computed だけでフロント完結フィルタ検索を実装した話
はじめに
映画一覧ページに検索・フィルタ機能を追加するとき、
「フィルタのたびにAPIを叩くか、フロントで絞り込むか」という判断があります。
このプロジェクトでは、初回に全データを取得して computed で絞り込む方式を採用しました。
なぜフロント完結にしたか
| 方式 | メリット | デメリット |
|---|---|---|
| フィルタごとにAPI呼び出し | データ量に依存しない | レスポンス待ちのたびにローディング |
| 初回全件取得 + computed | 操作が即座に反応 | 大量データには向かない |
このプロジェクトの映画数は1,000件程度(サンプルDB)。
1,000件の JSON を取得してもブラウザで十分扱えるサイズです。
実装
データ取得
// FilmsView.vue
import { ref, computed, onMounted } from 'vue'
import { fetchFilms } from '@/api/filmsApi'
import type { Film } from '@/types/film'
const allFilms = ref<Film[]>([])
onMounted(async () => {
allFilms.value = await fetchFilms()
})
フィルタ条件の状態
// フィルタ条件
const searchKeyword = ref('')
const selectedCategory = ref<string | null>(null)
const selectedRating = ref<string | null>(null)
const minRentalRate = ref<number | null>(null)
const maxRentalRate = ref<number | null>(null)
const sortOrder = ref<'title_asc' | 'title_desc' | 'rate_asc' | 'rate_desc'>('title_asc')
computed で絞り込み
const filteredFilms = computed(() => {
let result = allFilms.value
// キーワード検索(タイトル・説明文)
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.trim().toLowerCase()
result = result.filter(film =>
film.title.toLowerCase().includes(keyword) ||
film.description?.toLowerCase().includes(keyword)
)
}
// カテゴリーフィルタ
if (selectedCategory.value) {
result = result.filter(film =>
film.categoryName === selectedCategory.value
)
}
// レーティングフィルタ
if (selectedRating.value) {
result = result.filter(film =>
film.rating === selectedRating.value
)
}
// レンタル料金の範囲フィルタ
if (minRentalRate.value !== null) {
result = result.filter(film =>
film.rentalRate >= minRentalRate.value!
)
}
if (maxRentalRate.value !== null) {
result = result.filter(film =>
film.rentalRate <= maxRentalRate.value!
)
}
// ソート
return [...result].sort((a, b) => {
switch (sortOrder.value) {
case 'title_asc': return a.title.localeCompare(b.title)
case 'title_desc': return b.title.localeCompare(a.title)
case 'rate_asc': return a.rentalRate - b.rentalRate
case 'rate_desc': return b.rentalRate - a.rentalRate
default: return 0
}
})
})
テンプレートで使用
<template>
<div>
<!-- 検索フォーム -->
<input v-model="searchKeyword" placeholder="タイトルで検索..." />
<select v-model="selectedCategory">
<option :value="null">すべてのカテゴリ</option>
<option v-for="cat in categories" :key="cat" :value="cat">
{{ cat }}
</option>
</select>
<!-- 件数表示 -->
<p>{{ filteredFilms.length }}件 / {{ allFilms.length }}件中</p>
<!-- 結果一覧 -->
<div class="films-grid">
<FilmCard
v-for="film in paginatedFilms"
:key="film.filmId"
:film="film"
/>
</div>
</div>
</template>
ページネーションも computed で
フィルタの結果をページネーションするときも computed が便利です。
const ITEMS_PER_PAGE = 20
const currentPage = ref(1)
// フィルタが変わったらページを1に戻す
watch(filteredFilms, () => {
currentPage.value = 1
})
const totalPages = computed(() =>
Math.ceil(filteredFilms.value.length / ITEMS_PER_PAGE)
)
const paginatedFilms = computed(() => {
const start = (currentPage.value - 1) * ITEMS_PER_PAGE
return filteredFilms.value.slice(start, start + ITEMS_PER_PAGE)
})
フィルタリセット
function resetFilters() {
searchKeyword.value = ''
selectedCategory.value = null
selectedRating.value = null
minRentalRate.value = null
maxRentalRate.value = null
sortOrder.value = 'title_asc'
}
カテゴリ一覧を computed で動的生成
カテゴリの選択肢は、取得したデータから動的に作ります。
ハードコードしないことで、DB にカテゴリを追加すると自動でフィルタに反映されます。
const categories = computed(() => {
const set = new Set(
allFilms.value
.map(film => film.categoryName)
.filter((cat): cat is string => cat !== null && cat !== undefined)
)
return Array.from(set).sort()
})
computed のパフォーマンス特性
Vue 3 の computed はリアクティブな依存関係が変わらない限り、
前回の結果をキャッシュして再計算しません。
searchKeywordが変わった →filteredFilmsが再計算される- 別のコンポーネントが
allFilmsを変更しない限り → キャッシュが使われる
1,000件程度のデータなら、毎回再計算されても体感速度に問題はありません。
10万件を超えるようなデータ量になったら、サーバーサイドフィルタへの切り替えを検討します。
まとめ
フロント完結フィルタが向いているケース:
- データ総量が数千件程度
- ユーザーが頻繁にフィルタ条件を変える
- 操作のたびにローディングを見せたくない
サーバーサイドフィルタが向いているケース:
- データ総量が万件以上
- 初回ロードを軽くしたい
- 全文検索(形態素解析など)が必要
Vue 3 の computed は依存関係を自動追跡するため、
「searchKeyword が変わったら再計算」という設定を明示的に書かなくても動きます。
この仕組みを活かすと、フィルタ・ソート・ページネーションをシンプルに組み合わせられます。
このアプリでの実装
このDVDレンタルアプリでは記事で説明したパターンを実際に採用しています。 ただし、フィルタ条件が記事例より少し多く、カテゴリ・区分(新作/準新作/旧作)・長さ・キーワードの4種類を組み合わせています。
filteredFilms の実際のコード(FilmsView.vue)
const searchKeyword = ref('')
const selectedCategory = ref('ALL')
const appliedKeyword = ref('') // 検索ボタン押下で確定
const appliedCategory = ref('ALL')
const selectedTierCode = ref('ALL') // 新作/準新作/旧作
const selectedLengthFilter = ref('ALL')
const filteredFilms = computed(() => {
const keyword = appliedKeyword.value.trim().toLowerCase()
return films.value.filter((film) => {
// カテゴリフィルタ(サイドバーとツールバーselectの両方を考慮)
const catFilter = selectedCategory.value !== 'ALL' ? selectedCategory.value : appliedCategory.value
const categoryMatched = catFilter === 'ALL' || catFilter === (film.categoryName ?? 'その他')
if (!categoryMatched) return false
// 区分フィルタ(新作/準新作/旧作)
if (selectedTierCode.value !== 'ALL' && film.tierCode !== selectedTierCode.value) return false
// 長さフィルタ
if (selectedLengthFilter.value !== 'ALL') {
const len = film.length ?? 0
if (selectedLengthFilter.value === 'U60' && len > 60) return false
if (selectedLengthFilter.value === 'U90' && len > 90) return false
if (selectedLengthFilter.value === 'U120' && len > 120) return false
if (selectedLengthFilter.value === 'OVER120' && len <= 120) return false
}
// キーワード検索(タイトル・カテゴリ・説明文)
if (!keyword) return true
const title = film.title.toLowerCase()
const description = (film.shortDescription ?? '').toLowerCase()
return title.includes(keyword) || (film.categoryName ?? '').toLowerCase().includes(keyword) || description.includes(keyword)
})
})
カテゴリ一覧の動的生成(実際のコード)
const categoryOptions = computed(() => {
const categories = new Set<string>()
films.value.forEach((film) => categories.add(film.categoryName ?? 'その他'))
return Array.from(categories).sort((a, b) => a.localeCompare(b, 'ja'))
})
記事の例と同様に Set + Array.from で重複排除、日本語ロケールでソートしています。
実際の画面
フィルターで絞り込まれた作品一覧:

このシリーズの記事マップ
→ dvdrental 管理アプリと対になるエンドユーザー向けDVDレンタルアプリを作っている話 — Vue 3 + Spring Boot の全体構成と記事マップ