sauber verbinden — ohne reaktive Spaghetti
Wer Infinite Scroll, Pagination und Suchfilter nachträglich zusammenführt, kämpft mit Race Conditions, doppelten Requests und verlorenen URL-States. Vue.js bietet mit Composables, watchEffect und reaktiven Query-Parametern alle Werkzeuge, um diese drei Mechanismen von Anfang an sauber zu einer stabilen Such-UI zu verbinden.
Inhaltsverzeichnis
- 1. Das eigentliche Problem: drei Mechanismen, ein State
- 2. URL-State als Single Source of Truth
- 3. Suchfilter mit Debouncing — kein Request-Spam
- 4. useSearch Composable: Filter, Seite und Daten zusammenführen
- 5. Infinite Scroll mit IntersectionObserver implementieren
- 6. Pagination-Reset beim Filterwechsel — Race Conditions vermeiden
- 7. Loading States und Skeleton UI sauber abbilden
- 8. Fehlerbehandlung und leere Ergebnisseiten
- 9. Infinite Scroll vs. Pagination: wann welches Muster?
- 10. Zusammenfassung
- 11. FAQ
1. Das eigentliche Problem: drei Mechanismen, ein State
Die Herausforderung beim Verbinden von Vue Infinite Scroll, Pagination und Suchfilter liegt nicht in der Implementierung jeder einzelnen Funktion, sondern in der gemeinsamen Zustandsverwaltung. Jeder dieser Mechanismen greift auf dieselben Datenpunkte zu: die aktuelle Seite, die Suchbegriffe und die Gesamtergebnisliste. Wenn diese drei Zustände unabhängig voneinander in verschiedenen Komponenten oder Composables leben, entstehen Situationen, in denen ein Filterwechsel die Seite nicht zurücksetzt, oder Infinite Scroll weiterschreibt, obwohl der Nutzer schon einen neuen Suchbegriff eingegeben hat.
Das grundlegende Architekturprinzip für stabile Vue Suchfilter-Kombinationen ist: ein einziger, zentraler State für alle drei Mechanismen. Dieser State enthält den aktuellen Suchbegriff, alle aktiven Filterwerte, die aktuelle Seite und die gesammelten Ergebnisse. Alle Komponenten lesen aus diesem State und schreiben in ihn — niemals direkt in lokale ref-Variablen, die parallel existieren. Die URL wird als externe Projektion dieses States behandelt, nicht als eigene Datenquelle.
In der Praxis sieht man häufig, wie Entwickler Infinite Scroll in einer Komponente aufbauen, den Suchfilter in einer anderen, und beide dann über Events oder Props zu koordinieren versuchen. Das funktioniert bis zum ersten Edge Case: Was passiert, wenn der Nutzer scrollt, während ein neuer Request läuft? Was passiert bei einem Browser-Back? Diese Fragen beantwortet ein sauber aufgebautes Vue Pagination-Composable von Anfang an — statt sie nachträglich zu flicken.
2. URL-State als Single Source of Truth
Der URL-State ist das wichtigste Werkzeug für eine saubere Vue Suchfilter-Architektur. Wenn Suchbegriff, Filterparameter und aktuelle Seite in der URL stehen, können Nutzer die Ergebnisseite bookmarken, teilen und per Browser-Back navigieren — ohne dass die Anwendung den Zustand verliert. Vue Router bietet mit useRoute und useRouter direkten Zugriff auf die Query-Parameter, die als reaktive Datenquelle genutzt werden können.
Der entscheidende Punkt: Die Komponente selbst hält keinen lokalen Filter-State mehr. Stattdessen liest sie die Query-Parameter aus route.query und schreibt bei Nutzerinteraktionen neue Parameter mit router.push in die URL. Das watch(route.query, ...) reagiert auf Änderungen und löst den nächsten API-Request aus. Damit ist die URL der einzige Ort, an dem der aktuelle Suchzustand persistiert — synchron über Browser-History, Bookmarks und geteilte 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. Suchfilter mit Debouncing — kein Request-Spam
Suchfilter ohne Debouncing senden bei jedem Tastendruck einen API-Request. Bei einer Sucheingabe von „Laptop" werden sieben Requests abgeschickt — und je nach Netzwerklatenz kommen die Antworten in beliebiger Reihenfolge zurück. Das Ergebnis für „Lapt" könnte nach dem Ergebnis für „Laptop" ankommen und die Anzeige überschreiben. Dieses Race Condition-Problem ist ein Klassiker bei Vue Suchfilter-Implementierungen und tritt zuverlässig in der Produktion auf, wenn die Netzwerklatenz variiert.
Die Lösung besteht aus zwei Teilen: Debouncing der Eingabe und Abbrechen veralteter Requests. Das Debouncing verzögert den Schreibvorgang in die URL um 300–400 ms nach dem letzten Tastendruck. Das Abbrechen veralteter Requests geschieht mit dem AbortController: Jeder neue Request erstellt einen neuen Controller und bricht den vorherigen ab. So erreicht nur das Ergebnis des aktuellsten Requests die Anzeige — alle veralteten Responses werden ignoriert.
// 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: Filter, Seite und Daten zusammenführen
Ein zentrales useSearch-Composable ist das Herzstück der gesamten Vue Pagination- und Vue Infinite Scroll-Architektur. Es verbindet den URL-State aus useSearchQuery, das Debouncing aus useDebounce und die eigentliche API-Kommunikation zu einem einzigen, konsistenten Interface, das jede Komponente einfach aufrufen kann. Das Composable gibt reaktive Werte zurück: die aktuelle Ergebnisliste, den Loading-State, ob weitere Seiten verfügbar sind, und die Gesamtanzahl der Treffer.
Der watchEffect-Block im Composable beobachtet alle relevanten Inputs — debouncten Suchbegriff, aktive Filter, aktuelle Seite — und triggert automatisch einen neuen Fetch, wenn sich einer davon ändert. Für Infinite Scroll akkumuliert das Composable die Ergebnisse in einer wachsenden Liste. Für klassische Pagination ersetzt jeder Fetch die gesamte Liste. Dieser Unterschied ist ein Parameter beim Aufruf des Composables — so kann dieselbe Logik für beide Anzeigemodi verwendet werden.
5. Infinite Scroll mit IntersectionObserver implementieren
Vue Infinite Scroll mit dem nativen IntersectionObserver ist die performanteste Variante ohne externe Bibliotheken. Ein unsichtbares Sentinel-Element am Ende der Liste wird beobachtet. Sobald es in den Viewport scrollt, lädt das Composable die nächste Seite. Kein Scroll-Event-Listener, kein konstantes Berechnen von scrollTop + clientHeight — der Browser übernimmt die Erkennung nativ und effizient, auch auf mobilen Geräten mit variable Scroll-Geschwindigkeit.
Die Integration in Vue erfolgt über eine eigene useInfiniteScroll-Composable-Funktion, die ein Template-Ref als Argument erhält und den Observer intern verwaltet. Beim Unmount der Komponente wird der Observer automatisch getrennt, um Memory Leaks zu vermeiden. Ein wichtiger Guard: Der Observer triggert die Callback-Funktion erst, wenn kein aktiver Request läuft und noch weitere Seiten verfügbar sind. Ohne diesen Guard entstehen Doppel-Requests beim langsamen Scrollen.
// 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 beim Filterwechsel — Race Conditions vermeiden
Die häufigste Race Condition beim Verbinden von Vue Suchfilter und Vue Pagination entsteht, wenn der Nutzer den Filter ändert, während noch ein Request für Seite 3 läuft. Der Filter-Request für Seite 1 startet, aber der Seite-3-Request antwortet zuerst. Das Composable akkumuliert Seite 3 in die Liste, danach kommt Seite 1 und überschreibt sie — oder umgekehrt. Das Ergebnis ist eine inkonsistente Anzeige, die nur durch einen Reload korrigiert werden kann.
Die saubere Lösung kombiniert zwei Maßnahmen: Erstens den AbortController, der bei jedem neuen Filter-Request alle laufenden Requests abbricht. Zweitens ein Reset-Token — ein simpler Zähler, der bei jedem Filterwechsel inkrementiert wird. Jeder async Callback prüft nach dem Await, ob das Token noch mit dem aktuellen State übereinstimmt. Ist es veraltet, wird das Ergebnis verworfen, ohne die Anzeige zu verändern. Diese Kombination eliminiert alle Race Conditions, ohne auf komplexe State-Machines oder externe Bibliotheken angewiesen zu sein.
7. Loading States und Skeleton UI sauber abbilden
Eine saubere Vue Infinite Scroll-Implementierung unterscheidet drei verschiedene Loading-Zustände: den initialen Load, wenn noch gar keine Ergebnisse vorhanden sind; den Nachlade-State beim Scrollen zur nächsten Seite; und den Refresh-State, wenn sich die Filter ändern und die Liste neu aufgebaut wird. Diese drei Zustände sehen unterschiedlich aus in der UI — beim initialen Load zeigt man Skeleton Cards, beim Nachladen einen kleinen Spinner am Listenende, beim Refresh überblendet man die bestehende Liste.
Im Composable bildet man diese drei Zustände mit separaten Boolean-Flags ab: isInitialLoading, isLoadingMore und isRefreshing. Das Template wählt anhand dieser Flags die passende Darstellung. Wichtig: Alle drei Flags sind niemals gleichzeitig true. Das Composable stellt sicher, dass beim Setzen eines Flags die anderen zurückgesetzt werden — so gibt es keinen inkonsistenten Zwischenzustand in der Anzeige.
8. Fehlerbehandlung und leere Ergebnisseiten
Fehlerbehandlung in Vue Suchfilter-Composables ist oft unterentwickelt. API-Fehler werden mit console.error behandelt und der Loading-State bleibt hängen — der Nutzer sieht einen endlosen Spinner. Ein sauber aufgebautes Composable unterscheidet zwischen transienten Netzwerkfehlern, die mit einem Retry-Button behebbar sind, und permanenten Fehlern wie 404 oder 403, die eine andere Meldung erfordern. Das Error-Objekt im returned State trägt diese Information, und die Template-Komponente wählt die passende Darstellung.
Leere Ergebnisseiten sind ein eigener Zustand, der nicht mit dem Error-Zustand verwechselt werden darf. Wenn die API erfolgreich antwortet, aber null Treffer liefert, zeigt das UI einen Empty State mit konkreten Handlungsoptionen: Filter zurücksetzen, Suchbegriff ändern, oder direkt zu einer Kategorie wechseln. Diese Handlungsoptionen sind aus dem Empty-State-Template direkt mit den updateFilters- und goToPage-Funktionen aus dem useSearchQuery-Composable verknüpft — ohne Props oder 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">Erneut versuchen</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">Keine Ergebnisse für „{ { searchTerm } }"</p>
<button @click="updateFilters({ q: '', category: '' })" class="btn-secondary">
Filter zurücksetzen
</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: wann welches Muster?
Die Entscheidung zwischen Vue Infinite Scroll und klassischer Vue Pagination ist keine rein technische, sondern eine UX-Frage. Beide Muster haben klare Einsatzgebiete, und die Kombination beider in einer Anwendung ist legitim — mit unterschiedlichem Modus je nach Kontext.
| Kriterium | Infinite Scroll | Pagination | Empfehlung |
|---|---|---|---|
| Nutzerziel | Entdecken, Stöbern | Gezielte Suche, Vergleich | Feed: Infinite · Katalog: Pagination |
| SEO | Schwierig ohne SSR | Gut indexierbar | Pagination bei SEO-relevantem Content |
| Browser-Back | Scrollposition geht verloren | URL zeigt exakte Seite | URL-State für beide Varianten |
| Performance | DOM wächst unbegrenzt | Feste DOM-Größe pro Seite | Virtual Scroll ab 500+ Items |
| Filterwechsel | Liste muss komplett resettet werden | Seite 1 automatisch | Reset-Token im Composable |
In der Praxis empfiehlt sich eine hybride Strategie: Mobile Geräte nutzen Infinite Scroll, Desktop-Ansichten bieten Pagination. Diese Entscheidung trifft das Composable nicht — es stellt nur die Daten bereit. Die Template-Schicht wählt anhand eines Props oder eines Breakpoint-Werts die passende Darstellung. Das Composable selbst bleibt identisch, nur der Parameter mode: 'append' | 'replace' steuert, ob Ergebnisse akkumuliert oder ersetzt werden.
Mironsoft
Vue.js Frontend-Architektur, Composables und Such-UI-Entwicklung
Vue Such-UIs, die auch unter echten Bedingungen stabil bleiben?
Wir bauen Vue.js Suchfilter, Infinite Scroll und Pagination als saubere Composable-Architektur — mit URL-State, Debouncing und Race-Condition-Schutz von Anfang an.
Composable-Architektur
useSearch, useDebounce, useInfiniteScroll als wiederverwendbare Einheiten
URL-State-Integration
Query-Parameter als Single Source of Truth für Filter und Seitenstand
Race-Condition-Schutz
AbortController und Reset-Token eliminieren veraltete Request-Ergebnisse
10. Zusammenfassung
Das Verbinden von Vue Infinite Scroll, Vue Pagination und Vue Suchfilter erfordert eine klare Architekturentscheidung: einen einzigen, zentralen State, der in der URL persistiert und von allen Komponenten geteilt wird. Das useSearch-Composable verbindet URL-State, debouncte Eingaben und API-Kommunikation zu einem konsistenten Interface. Der AbortController eliminiert veraltete Request-Ergebnisse. Das Reset-Token verhindert Race Conditions beim Filterwechsel. Der IntersectionObserver implementiert Infinite Scroll ohne Scroll-Event-Listener.
Diese Architektur skaliert, weil jede Verantwortlichkeit in einem eigenen Composable liegt und die Komponenten nur noch die Darstellung übernehmen. Das Hinzufügen eines neuen Filterparameters bedeutet: URL-Query-Feld ergänzen, im Composable beobachten — fertig. Kein Event-Bus, keine tief verschachtelten Props, keine Koordinationsprobleme zwischen voneinander unabhängigen State-Inseln.
Vue Infinite Scroll, Pagination und Suchfilter — Das Wichtigste auf einen Blick
URL als Single Source of Truth
Alle Filterparameter und die aktuelle Seite leben in der URL. Browser-Back, Bookmarks und geteilte Links funktionieren damit automatisch.
Debouncing + AbortController
Debouncing verhindert Request-Spam. AbortController bricht veraltete Requests ab. Beide zusammen eliminieren Race Conditions bei schneller Eingabe.
IntersectionObserver
Sentinel-Element am Listenende, nativ vom Browser beobachtet. Kein Scroll-Listener, kein manuelles Berechnen von Positionen.
Drei Loading-Zustände
isInitialLoading, isLoadingMore und isRefreshing sind niemals gleichzeitig true. Jeder Zustand hat eine eigene UI-Darstellung im Template.