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.
Table of Contents
- 1. The real problem: three mechanisms, one state
- 2. URL state as the single source of truth
- 3. Search filters with debouncing, no request spam
- 4. useSearch composable: combining filters, page and data
- 5. Implementing infinite scroll with IntersectionObserver
- 6. Pagination reset on filter change, avoiding race conditions
- 7. Modeling loading states and skeleton UI cleanly
- 8. Error handling and empty result pages
- 9. Infinite scroll vs. pagination: which pattern, when?
- 10. Summary
- 11. FAQ
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.