Implementing Buttery-Smooth Infinite Scroll in Vue 3 with Composables

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:

Feb 16, 2025 - 22:13
 0
Implementing Buttery-Smooth Infinite Scroll in Vue 3 with Composables

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:



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),
        'meta' => [
          'has_more' => $posts->hasMorePages(),
          'current_page' => $posts->currentPage(),
        ],
      ]);
    }

    return Inertia::render('Blog/Index');
  }
}

Key Points:

  • Classic pagination with Eloquent
  • Resource transformation for consistent API responses
  • has_more meta field 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<T> = {
  initialData: T[];
  apiUrl: string;
  dataKey: string; // Key for nested data (e.g., 'posts')
  pageParam?: string;
  metaKey?: 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
  initialData = [],
  apiUrl,
  dataKey,
  pageParam = "page",
  metaKey = "has_more",
  getParams = () => ({}),
  observerOptions = {
    root: null,
    rootMargin: "0px",
    threshold: 0.1, // 10% visibility triggers load
  },
}: InfiniteScrollOptions<T>): InfiniteScrollReturn<T> {
  // Reactive state management
  const data = ref<T[]>(initialData) 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.meta[metaKey];
      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 = initialData;
    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();
    });

    // Initial load if empty
    if (data.value.length === 0) {
      loadMore();
    }
  });

  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:


<script setup lang="ts">
import { useInfiniteScroll } from '@/Composables/useInfiniteScroll';

type Post = {
  id: number;
  title: string;
  // ...
}

const {
  data: posts,
  sentinelRef,
  isLoading,
  error,
  loadMore,
} = useInfiniteScroll<Post>({
  initialData: [],
  apiUrl: route('blog.index'), // Dynamic URL
  dataKey: 'posts', // Matches backend response
});
script>

<template>
   class="post-feed">
    
     
      v-for="post in posts"
      :key="post.id"
      class="post-card"
    >
      

{{ post.title }}

v-if="post.media" :src="post.media.url" :alt="post.media.alt" >

{{ post.excerpt }} v-if="isLoading" class="loading-indicator"> class="spinner">

Loading more posts...
v-if="error" class="error-message"> {{ error }} @click="loadMore">Retry