Tech Blog

Implementing Front-End Complete Filter Search with Vue 3 computed

by Tech Writer
Vue3 TypeScript Frontend

Introduction

When adding search and filter features to a film list page,
there’s a choice: “Call the API on every filter, or narrow down on the frontend?”

This project adopted the approach of fetching all data once and narrowing down with computed.


Why We Chose Frontend-Complete

ApproachAdvantagesDisadvantages
API call on each filterNot dependent on data volumeLoading on every response wait
Full fetch + computedOperations respond instantlyNot suitable for large data volumes

This project has about 1,000 films (sample DB).
Fetching 1,000 items of JSON is a size that browsers can handle comfortably.


Implementation

Data Fetching

// 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()
})

Filter Condition State

// Filter conditions
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')

Filtering with computed

const filteredFilms = computed(() => {
  let result = allFilms.value

  // Keyword search (title and description)
  if (searchKeyword.value.trim()) {
    const keyword = searchKeyword.value.trim().toLowerCase()
    result = result.filter(film =>
      film.title.toLowerCase().includes(keyword) ||
      film.description?.toLowerCase().includes(keyword)
    )
  }

  // Category filter
  if (selectedCategory.value) {
    result = result.filter(film =>
      film.categoryName === selectedCategory.value
    )
  }

  // Rating filter
  if (selectedRating.value) {
    result = result.filter(film =>
      film.rating === selectedRating.value
    )
  }

  // Rental rate range filter
  if (minRentalRate.value !== null) {
    result = result.filter(film =>
      film.rentalRate >= minRentalRate.value!
    )
  }
  if (maxRentalRate.value !== null) {
    result = result.filter(film =>
      film.rentalRate <= maxRentalRate.value!
    )
  }

  // Sort
  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
    }
  })
})

Using in the Template

<template>
  <div>
    <!-- Search form -->
    <input v-model="searchKeyword" placeholder="Search by title..." />
    
    <select v-model="selectedCategory">
      <option :value="null">All Categories</option>
      <option v-for="cat in categories" :key="cat" :value="cat">
        {{ cat }}
      </option>
    </select>

    <!-- Count display -->
    <p>{{ filteredFilms.length }} / {{ allFilms.length }} results</p>

    <!-- Result list -->
    <div class="films-grid">
      <FilmCard
        v-for="film in paginatedFilms"
        :key="film.filmId"
        :film="film"
      />
    </div>
  </div>
</template>

Pagination Also with computed

computed is also convenient for paginating filter results.

const ITEMS_PER_PAGE = 20
const currentPage = ref(1)

// Reset to page 1 when filter changes
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)
})

Filter Reset

function resetFilters() {
  searchKeyword.value = ''
  selectedCategory.value = null
  selectedRating.value = null
  minRentalRate.value = null
  maxRentalRate.value = null
  sortOrder.value = 'title_asc'
}

Dynamically Generating Category List with computed

Category options are generated dynamically from the fetched data.
By not hardcoding, when a category is added to the DB, it automatically reflects in the filter.

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()
})

Performance Characteristics of computed

Vue 3’s computed doesn’t recalculate and returns the cached previous result
as long as reactive dependencies don’t change.

  • searchKeyword changes → filteredFilms recalculates
  • As long as nothing changes allFilms → cache is used

For data of about 1,000 items, even if recalculated every time, there’s no perceptible performance issue.
If the data volume exceeds 100,000 items, we’d consider switching to server-side filtering.


Summary

Cases where frontend-complete filtering is suitable:
- Total data volume is in the thousands
- Users frequently change filter conditions
- Don't want to show loading on every operation

Cases where server-side filtering is suitable:
- Total data volume is in the tens of thousands or more
- Want to keep initial load light
- Full-text search (morphological analysis, etc.) is required

Vue 3’s computed automatically tracks dependencies,
so “recalculate when searchKeyword changes” works without being explicitly written.
Leveraging this mechanism lets you simply combine filtering, sorting, and pagination.


Actual Implementation in This App

This DVD rental app actually adopts the pattern described in this article.
However, the filter conditions are slightly more than the article example, combining 4 types: category, tier (new/semi-new/old), length, and keyword.

Actual Code for filteredFilms (FilmsView.vue)

const searchKeyword = ref('')
const selectedCategory = ref('ALL')
const appliedKeyword = ref('')    // Confirmed when search button is pressed
const appliedCategory = ref('ALL')
const selectedTierCode = ref('ALL')   // New/semi-new/old
const selectedLengthFilter = ref('ALL')

const filteredFilms = computed(() => {
  const keyword = appliedKeyword.value.trim().toLowerCase()
  return films.value.filter((film) => {
    // Category filter (considering both sidebar and toolbar select)
    const catFilter = selectedCategory.value !== 'ALL' ? selectedCategory.value : appliedCategory.value
    const categoryMatched = catFilter === 'ALL' || catFilter === (film.categoryName ?? 'Other')
    if (!categoryMatched) return false

    // Tier filter (new/semi-new/old)
    if (selectedTierCode.value !== 'ALL' && film.tierCode !== selectedTierCode.value) return false

    // Length filter
    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
    }

    // Keyword search (title, category, description)
    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)
  })
})

Dynamic Category List Generation (Actual Code)

const categoryOptions = computed(() => {
  const categories = new Set<string>()
  films.value.forEach((film) => categories.add(film.categoryName ?? 'Other'))
  return Array.from(categories).sort((a, b) => a.localeCompare(b, 'ja'))
})

Same as the article example, using Set + Array.from for deduplication, sorted with Japanese locale.

Actual Screen

Film list filtered by conditions:

Film list filter


Article Map for This Series

Building a DVD Rental End-User App alongside the Admin App — Vue 3 + Spring Boot Full Architecture and Article Map

Feel free to send a message

Please send a message if you have any technical questions, feedback, or inquiries.