Tech Blog

Vue 3 computed だけでフロント完結フィルタ検索を実装した話

Vue3 TypeScript Frontend

はじめに

映画一覧ページに検索・フィルタ機能を追加するとき、
「フィルタのたびに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 の全体構成と記事マップ

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。