Connecting Infinite Scroll, Pagination and Search Filters Cleanly with Vue
AI generated
<v/>
{ }
Vue.js · Infinite Scroll · Pagination · Search Filters · Composables
Connecting Infinite Scroll, Pagination and Search Filters with Vue
cleanly, without reactive spaghetti

Anyone who bolts infinite scroll, pagination and search filters together after the fact ends up fighting race conditions, duplicate requests and lost URL state. Vue.js provides composables, watchEffect and reactive query parameters, all the tools needed to connect these three mechanisms cleanly into one stable search UI from the very start.

18 min read Composables · URL State · Debouncing · IntersectionObserver · Vue Router Vue 3 · Composition API · TypeScript

1. The real problem: three mechanisms, one state

The challenge in connecting Vue infinite scroll, pagination and search filters lies not in implementing each individual feature, but in the shared state management. Each of these mechanisms accesses the same data points: the current page, the search terms and the overall result list. When these three states live independently in separate components or composables, situations arise where a filter change fails to reset the page, or infinite scroll keeps appending even though the user has already entered a new search term.

The fundamental architecture principle for stable Vue search filter combinations is: a single, centralized state for all three mechanisms. This state holds the current search term, all active filter values, the current page and the accumulated results. Every component reads from this state and writes into it, never directly into local ref variables that exist in parallel. The URL is treated as an external projection of this state, not as its own data source.

In practice, developers often build infinite scroll in one component, the search filter in another, and then try to coordinate both through events or props. That works until the first edge case: what happens if the user scrolls while a new request is in flight? What happens on a browser back navigation? A cleanly designed Vue pagination composable answers these questions from the outset, rather than patching them in afterward.

2. URL state as the single source of truth

URL state is the most important tool for a clean Vue search filter architecture. When the search term, filter parameters and current page live in the URL, users can bookmark the result page, share it and navigate via browser back, without the application losing its state. Vue Router provides direct access to the query parameters through useRoute and useRouter, which can be used as a reactive data source.

The crucial point: the component itself no longer holds any local filter state. Instead it reads the query parameters from route.query and, on user interaction, writes new parameters into the URL with router.push. The watch(route.query, ...) reacts to changes and triggers the next API request. This makes the URL the only place where the current search state persists, in sync across browser history, bookmarks and shared links.


// composables/useSearchQuery.ts
// Manages URL query params as the single source of truth for filters
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'

export function useSearchQuery() {
  const route = useRoute()
  const router = useRouter()

  // Read current filter state from URL
  const searchTerm = computed(() => (route.query.q as string) ?? '')
  const currentPage = computed(() => Number(route.query.page ?? 1))
  const activeCategory = computed(() => (route.query.category as string) ?? '')

  // Write new filter state back to URL (replaces history entry for filters, pushes for page)
  function updateFilters(updates: Record<string, string | number | undefined>) {
    router.push({
      query: {
        ...route.query,
        page: 1,   // always reset page on filter change
        ...updates,
      },
    })
  }

  function goToPage(page: number) {
    router.push({ query: { ...route.query, page } })
  }

  return { searchTerm, currentPage, activeCategory, updateFilters, goToPage }
}

3. Search filters with debouncing, no request spam

Search filters without debouncing send an API request on every keystroke. Typing "Laptop" fires off seven requests, and depending on network latency, the responses can come back in any order. The result for "Lapt" might arrive after the result for "Laptop" and overwrite the display. This race condition problem is a classic in Vue search filter implementations and shows up reliably in production whenever network latency varies.

The solution has two parts: debouncing the input and cancelling stale requests. Debouncing delays writing to the URL by 300 to 400 ms after the last keystroke. Cancelling stale requests happens with AbortController: every new request creates a new controller and aborts the previous one. That way only the result of the most recent request ever reaches the display, all stale responses are ignored.


// composables/useDebounce.ts
// Debounces a reactive value to prevent rapid-fire API calls
import { ref, watch } from 'vue'

export function useDebounce<T>(source: () => T, delay = 350) {
  const debounced = ref<T>(source())
  let timer: ReturnType<typeof setTimeout>

  watch(source, (value) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      debounced.value = value as T
    }, delay)
  })

  return debounced
}

// In useSearch.ts: abort previous fetch when a new one starts
let abortController: AbortController | null = null

async function fetchResults(params: SearchParams) {
  // Cancel in-flight request
  abortController?.abort()
  abortController = new AbortController()

  try {
    const data = await api.search(params, { signal: abortController.signal })
    return data
  } catch (err) {
    if ((err as Error).name === 'AbortError') return null  // expected, ignore
    throw err
  }
}

4. useSearch composable: combining filters, page and data

A central useSearch composable is the heart of the entire Vue pagination and Vue infinite scroll architecture. It connects the URL state from useSearchQuery, the debouncing from useDebounce and the actual API communication into a single, consistent interface that every component can simply call. The composable returns reactive values: the current result list, the loading state, whether more pages are available, and the total number of hits.

The watchEffect block inside the composable observes all relevant inputs, the debounced search term, active filters, current page, and automatically triggers a new fetch whenever one of them changes. For infinite scroll, the composable accumulates the results in a growing list. For classic pagination, each fetch replaces the entire list. This distinction is just a parameter passed to the composable, so the same logic can serve both display modes.

5. Implementing infinite scroll with IntersectionObserver

Vue infinite scroll built on the native IntersectionObserver is the most performant variant without external libraries. An invisible sentinel element at the end of the list is observed. As soon as it scrolls into the viewport, the composable loads the next page. No scroll event listener, no constant recalculating of scrollTop + clientHeight, the browser handles detection natively and efficiently, even on mobile devices with variable scroll speed.

Integration into Vue happens through a dedicated useInfiniteScroll composable function, which takes a template ref as an argument and manages the observer internally. When the component unmounts, the observer is automatically disconnected to avoid memory leaks. An important guard: the observer only triggers the callback function when no active request is running and more pages are still available. Without this guard, slow scrolling produces duplicate requests.


// composables/useInfiniteScroll.ts
// Triggers loadMore callback when sentinel element enters the viewport
import { onMounted, onUnmounted, watch, type Ref } from 'vue'

export function useInfiniteScroll(
  sentinel: Ref<HTMLElement | null>,
  loadMore: () => void,
  canLoadMore: Ref<boolean>
) {
  let observer: IntersectionObserver | null = null

  function setupObserver() {
    if (!sentinel.value) return
    observer?.disconnect()

    observer = new IntersectionObserver(
      ([entry]) => {
        // Only trigger if sentinel is visible and more pages are available
        if (entry.isIntersecting && canLoadMore.value) {
          loadMore()
        }
      },
      { rootMargin: '200px' }  // start loading 200px before sentinel is visible
    )
    observer.observe(sentinel.value)
  }

  onMounted(setupObserver)
  watch(sentinel, setupObserver)
  onUnmounted(() => observer?.disconnect())
}

6. Pagination reset on filter change, avoiding race conditions

The most common race condition when connecting Vue search filters and Vue pagination occurs when the user changes the filter while a request for page 3 is still in flight. The filter request for page 1 starts, but the page 3 request responds first. The composable accumulates page 3 into the list, then page 1 arrives and overwrites it, or the other way around. The result is an inconsistent display that can only be corrected by a reload.

The clean solution combines two measures: first, the AbortController, which cancels all running requests whenever a new filter request starts. Second, a reset token, a simple counter that gets incremented on every filter change. Every async callback checks after the await whether the token still matches the current state. If it is stale, the result is discarded without altering the display. This combination eliminates all race conditions without relying on complex state machines or external libraries.

7. Modeling loading states and skeleton UI cleanly

A clean Vue infinite scroll implementation distinguishes three different loading states: the initial load, when no results exist yet at all; the load-more state while scrolling to the next page; and the refresh state, when filters change and the list gets rebuilt. These three states look different in the UI, the initial load shows skeleton cards, loading more shows a small spinner at the end of the list, and a refresh cross-fades the existing list.

In the composable, these three states are modeled with separate boolean flags: isInitialLoading, isLoadingMore and isRefreshing. The template picks the appropriate presentation based on these flags. Important: all three flags are never true at the same time. The composable ensures that whenever one flag is set, the others are reset, so there is no inconsistent intermediate state in the display.

8. Error handling and empty result pages

Error handling in Vue search filter composables is often underdeveloped. API errors are handled with console.error and the loading state gets stuck, the user sees an endless spinner. A cleanly designed composable distinguishes between transient network errors, which can be fixed with a retry button, and permanent errors like 404 or 403, which require a different message. The error object in the returned state carries this information, and the template component chooses the appropriate presentation.

Empty result pages are their own state, which must not be confused with the error state. When the API responds successfully but returns zero hits, the UI shows an empty state with concrete action options: reset filters, change the search term, or jump directly to a category. These action options are wired directly from the empty state template to the updateFilters and goToPage functions from the useSearchQuery composable, without props or events.


// components/SearchResults.vue -- template section
// Shows appropriate UI for each state: loading, error, empty, results

<template>
  <div>
    <!-- Initial skeleton loading state -->
    <template v-if="isInitialLoading">
      <SkeletonCard v-for="n in 6" :key="n" />
    </template>

    <!-- Error state with retry -->
    <div v-else-if="error" class="text-center py-12">
      <p class="text-red-600 font-semibold mb-4">{{ error.message }}</p>
      <button @click="retry" class="btn-primary">Try again</button>
    </div>

    <!-- Empty state with actionable options -->
    <div v-else-if="!isInitialLoading && results.length === 0" class="text-center py-12">
      <p class="text-slate-600 mb-4">No results for "{{ searchTerm }}"</p>
      <button @click="updateFilters({ q: '', category: '' })" class="btn-secondary">
        Reset filters
      </button>
    </div>

    <!-- Results with infinite scroll sentinel -->
    <template v-else>
      <ResultCard v-for="item in results" :key="item.id" :item="item" />
      <!-- Sentinel triggers next page load via IntersectionObserver -->
      <div ref="sentinel" class="h-4" aria-hidden="true"></div>
      <div v-if="isLoadingMore" class="text-center py-4">
        <LoadingSpinner size="sm" />
      </div>
    </template>
  </div>
</template>

9. Infinite scroll vs. pagination: which pattern, when?

The choice between Vue infinite scroll and classic Vue pagination is not purely a technical decision, it is a UX question. Both patterns have clear use cases, and combining them within one application is legitimate, with a different mode depending on context.

Criterion Infinite Scroll Pagination Recommendation
User goal Discovery, browsing Targeted search, comparison Feed: infinite. Catalog: pagination
SEO Difficult without SSR Well indexable Pagination for SEO-relevant content
Browser back Scroll position is lost URL points to the exact page URL state for both variants
Performance DOM grows unbounded Fixed DOM size per page Virtual scroll from 500+ items
Filter change List must be completely reset Page 1 automatically Reset token in the composable

In practice, a hybrid strategy is often the best approach: mobile devices use infinite scroll, desktop views offer pagination. The composable itself does not make this decision, it merely provides the data. The template layer picks the appropriate presentation based on a prop or a breakpoint value. The composable stays identical, only the mode: 'append' | 'replace' parameter controls whether results get accumulated or replaced.

Mironsoft

Vue.js frontend architecture, composables and search UI development

Vue search UIs that stay stable under real conditions?

We build Vue.js search filters, infinite scroll and pagination as a clean composable architecture, with URL state, debouncing and race-condition protection from the start.

Composable architecture

useSearch, useDebounce, useInfiniteScroll as reusable units

URL state integration

Query parameters as the single source of truth for filters and page state

Race-condition protection

AbortController and reset tokens eliminate stale request results

10. Summary

Connecting Vue infinite scroll, Vue pagination and Vue search filters requires a clear architecture decision: a single, centralized state that persists in the URL and is shared by every component. The useSearch composable connects URL state, debounced input and API communication into one consistent interface. The AbortController eliminates stale request results. The reset token prevents race conditions on filter change. The IntersectionObserver implements infinite scroll without a scroll event listener.

This architecture scales because every responsibility lives in its own composable and the components only take care of presentation. Adding a new filter parameter means: add a URL query field, observe it in the composable, done. No event bus, no deeply nested props, no coordination problems between independent islands of state.

Vue Infinite Scroll, Pagination and Search Filters, the Essentials at a Glance

URL as single source of truth

All filter parameters and the current page live in the URL. Browser back, bookmarks and shared links work automatically as a result.

Debouncing + AbortController

Debouncing prevents request spam. AbortController cancels stale requests. Together they eliminate race conditions on fast input.

IntersectionObserver

A sentinel element at the end of the list, observed natively by the browser. No scroll listener, no manual position calculations.

Three loading states

isInitialLoading, isLoadingMore and isRefreshing are never true at the same time. Each state has its own UI presentation in the template.

11. FAQ: Vue Infinite Scroll, Pagination and Search Filters

1How do I prevent race conditions with Vue search filters?
AbortController cancels stale requests. A reset token checks after the await whether the result is still relevant, otherwise it is discarded.
2Why URL state instead of Pinia for search filters?
URL state survives a reload and is shareable. Pinia state is lost on refresh and needs manual serialization for browser back.
3Vue infinite scroll without an external library?
IntersectionObserver on a sentinel element at the end of the list. No scroll listener, no manual position calculations. Native browser API, efficient on mobile devices.
4Automatically reset the page on filter change?
updateFilters always writes page: 1 to the URL before the new filter parameters. watchEffect automatically reacts with a new request for page 1.
5Optimal debounce delay for search filters?
300 to 400 ms. Below that, too many requests. Above that, the search feels sluggish. For local search without an API, debouncing is unnecessary.
6When infinite scroll, when pagination?
Infinite scroll for feeds and discovery UIs. Pagination for targeted search, SEO-relevant pages, and navigation to a specific result page.
7Distinguishing isInitialLoading from isLoadingMore?
isInitialLoading: list is empty, first request is running. isLoadingMore: results are visible, next page is being fetched. Both never true at the same time.
8Handling API errors during infinite scroll?
Set an error ref, set isLoadingMore to false. The template shows a retry button at the end of the list. Already-loaded results stay visible.
9DOM performance with long lists?
From 500+ items, use virtual scrolling with vue-virtual-scroller. Below that, native infinite scroll without virtualization is performant enough.
10One composable for both modes?
Yes, mode: 'append' for infinite scroll, mode: 'replace' for pagination. Fetch logic, error handling and URL state are identical.