In my previous guide we covered combining filters, sorting, and infinite scrolling in Laravel, Inertia.js v2, and Vue 3.
In this guide, let's tackle a pure Vue 3 solution for infinite scrolling.
Think of this as the "vanilla JavaScript" approach to infinite scroll - more flexible, more powerful, and surprisingly elegant when done right.
Why Use Composable?
Infinite scrolling seems simple until you consider:
- Network request management
- Scroll position restoration
- Memory efficiency
- Error handling
- Observer cleanup
Our composable will handle all these concerns while remaining flexible enough to drop into any component. It's like building a well-oiled machine that quietly does its job in the background.
The Laravel Backend
First, let's set up our Laravel endpoint. We'll keep it simple:
<?php
namespace App\Http\Controllers;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
class BlogController extends Controller
{
public function index(Request $request)
{
if ($request->wantsJson()) {
$posts = Post::paginate(12);
return response()->json([
'posts' => PostResource::collection($posts),
'has_more' => $posts->hasMorePages(),
]);
}
return Inertia::render('Blog/Index');
}
}
Key Points:
- Classic pagination with Eloquent
- Resource transformation for consistent API responses
-
has_more
flag is our stop signal - Clean separation between HTML/JSON responses
The Infinite Scroll Composable
Here's our workhorse - the useInfiniteScroll
composable. This is where the magic happens, think of it as your personal scroll concierge:
// Composables/useInfiniteScroll.ts
import { ref, Ref, onMounted, onBeforeUnmount, watch } from "vue";
import axios, { CancelTokenSource } from "axios";
type InfiniteScrollOptions = {
apiUrl: string;
dataKey: string; // Key for nested data (e.g., 'posts')
pageParam?: string;
hasMoreFlag?: string;
getParams?: () => Record<string, any>;
observerOptions?: IntersectionObserverInit;
};
type InfiniteScrollReturn<T> = {
data: Ref<T[]>;
isLoading: Ref<boolean>;
error: Ref<string | null>;
hasMore: Ref<boolean>;
currentPage: Ref<number>;
sentinelRef: Ref<HTMLElement | null>;
loadMore: () => Promise<void>;
reset: () => void;
};
export function useInfiniteScroll<T>({
// Configuration defaults
apiUrl,
dataKey,
pageParam = "page",
hasMoreFlag = "has_more",
getParams = () => ({}),
observerOptions = {
root: null,
rootMargin: "0px",
threshold: 0.1, // 10% visibility triggers load
},
}: InfiniteScrollOptions): InfiniteScrollReturn<T> {
// Reactive state management
const data = ref<T[]>([]) as Ref<T[]>;
const isLoading = ref(false);
const hasMore = ref(true);
const error = ref<string | null>(null);
const currentPage = ref(1);
const sentinelRef = ref<HTMLElement | null>(null);
const cancelTokenSource = ref<CancelTokenSource | null>(null);
let observer: IntersectionObserver | null = null;
// Core data loader
const loadMore = async () => {
if (!hasMore.value || isLoading.value) return;
isLoading.value = true;
error.value = null;
try {
// Cancel previous request
if (cancelTokenSource.value) {
cancelTokenSource.value.cancel();
}
// Create new cancellation token
cancelTokenSource.value = axios.CancelToken.source();
const response = await axios.get(apiUrl, {
headers: { Accept: "application/json" },
params: {
...getParams(),
[pageParam]: currentPage.value,
},
cancelToken: cancelTokenSource.value.token,
});
// Data extraction (supports nested responses)
const newData: T[] = response.data[dataKey]?.data || response.data[dataKey];
if (!newData || newData.length === 0) {
hasMore.value = false;
return;
}
// Update state
data.value = [...data.value, ...newData];
hasMore.value = response.data[hasMoreFlag];
currentPage.value += 1;
} catch (err) {
if (!axios.isCancel(err)) {
error.value = axios.isAxiosError(err)
? err.response?.data?.message || err.message
: "Failed to load more data.";
console.error("Error loading more data:", err);
}
} finally {
isLoading.value = false;
cancelTokenSource.value = null;
}
};
// Observer setup
const initializeObserver = () => {
if (!sentinelRef.value) return;
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && hasMore.value && !isLoading.value) {
loadMore();
}
});
}, observerOptions);
observer.observe(sentinelRef.value);
};
// Cleanup crew
const cleanup = () => {
observer?.disconnect();
if (cancelTokenSource.value) {
cancelTokenSource.value.cancel("Component unmounted");
}
};
// Reset to initial state
const reset = () => {
data.value = [];
hasMore.value = true;
currentPage.value = 1;
error.value = null;
};
onMounted(() => {
initializeObserver();
// Re-initialize observer if sentinelRef changes
watch(sentinelRef, (newVal, oldVal) => {
if (oldVal) observer?.unobserve(oldVal);
if (newVal) initializeObserver();
});
});
onBeforeUnmount(cleanup);
return {
data,
isLoading,
error,
hasMore,
currentPage,
sentinelRef,
loadMore,
reset,
};
}
Using the Composable in Components
Here's how you'd implement it in a Vue component:
<!-- Blog/Index.vue -->
<script setup lang="ts">
import { useInfiniteScroll } from '@/Composables/useInfiniteScroll';
type Post = {
id: number;
title: string;
// ...
}
const {
data: posts,
sentinelRef,
isLoading,
error,
loadMore,
} = useInfiniteScroll<Post>({
apiUrl: route('blog.index'), // Dynamic URL
dataKey: 'posts', // Matches backend response
});
</script>
<template>
<div class="post-feed">
<!-- Post List -->
<article
v-for="post in posts"
:key="post.id"
class="post-card"
>
<h3>{{ post.title }}</h3>
<img
v-if="post.media"
:src="post.media.url"
:alt="post.media.alt"
>
<p>{{ post.excerpt }}</p>
</article>
<!-- Loading State -->
<div v-if="isLoading" class="loading-indicator">
<div class="spinner"></div>
Loading more posts...
</div>
<!-- Error State -->
<div v-if="error" class="error-message">
{{ error }}
<button @click="loadMore">Retry</button>
</div>
<!-- Invisible Sentinel -->
<div
ref="sentinelRef"
class="scroll-sentinel"
:aria-hidden="true"
></div>
</div>
</template>
Key Features Explained
Intelligent Observation
The sentinel div acts as a tripwire - when it enters the viewport, we trigger the next load. The observer options let you fine-tune when this happens.Request Management
Automatic cancellation of in-flight requests prevents race conditions and memory leaks. Think of it as a "last request wins" system.State Management
All loading states, errors, and pagination data are handled internally, exposing clean reactive properties to your component.Flexible Configuration
Need to add custom params? Use thegetParams
function. Different API structure? Adjust the has more flag and data key response mapping.Self-Cleaning
Proper observer and request cleanup on component unmount
Why This Approach Shines
Reusability
Drop this composable into any component needing infinite scroll. Blog posts? Comments? Product listings? It just works.Performance
The Intersection Observer API is highly optimized, avoiding expensive scroll listeners. Combined with proper request cancellation, it's lightweight.Resilience
Network errors get handled gracefully with retry capability. Scroll position is maintained during loads.Maintainability
All complex logic lives in the composable, keeping components clean and focused on presentation.
Pro Tips for Production
- Threshold Tuning: Adjust based on your content height:
observerOptions = {
threshold: 0.05 // Trigger when 5% visible
}
- Retry Logic: Add exponential backoff to error handler:
const retryCount = ref(0);
// In catch block:
if (err.isNetworkError) {
await new Promise(r => setTimeout(r, 2 ** retryCount.value * 1000));
loadMore();
retryCount.value++;
}
- Edge Case Handling: Add window resize listener to refresh observer:
onMounted(() => {
window.addEventListener('resize', initializeObserver);
})
Leveling Up
Want to enhance this further?
Scroll Restoration
Add a scroll position memory system using the History APIVirtualization
Implement windowing for huge datasetsOffline Support
Add a local cache layer using IndexedDB
Final Thoughts
Inertia.js V2 offers great shortcuts (as shown in my previous guide), but understanding the vanilla Vue 3 approach gives you ultimate flexibility. This composable is like building your own sports car - you control every aspect of the performance.
The beauty of this pattern is in its separation of concerns. The composable handles all the complex machinery, while your components stay clean and focused on what matters - displaying content beautifully.
Stay inspired, keep building!
Top comments (1)
Where's the DEMO?