DEV Community

Cover image for Implementing Buttery-Smooth Infinite Scroll in Vue 3 with Composables
Deon Okonkwo
Deon Okonkwo

Posted on

3

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:

<?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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Key Features Explained

  1. 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.

  2. Request Management

    Automatic cancellation of in-flight requests prevents race conditions and memory leaks. Think of it as a "last request wins" system.

  3. State Management

    All loading states, errors, and pagination data are handled internally, exposing clean reactive properties to your component.

  4. Flexible Configuration

    Need to add custom params? Use the getParams function. Different API structure? Adjust the has more flag and data key response mapping.

  5. Self-Cleaning
    Proper observer and request cleanup on component unmount


Why This Approach Shines

  1. Reusability

    Drop this composable into any component needing infinite scroll. Blog posts? Comments? Product listings? It just works.

  2. Performance

    The Intersection Observer API is highly optimized, avoiding expensive scroll listeners. Combined with proper request cancellation, it's lightweight.

  3. Resilience

    Network errors get handled gracefully with retry capability. Scroll position is maintained during loads.

  4. Maintainability

    All complex logic lives in the composable, keeping components clean and focused on presentation.


Pro Tips for Production

  1. Threshold Tuning: Adjust based on your content height:
   observerOptions = {
     threshold: 0.05 // Trigger when 5% visible
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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++;
   }
Enter fullscreen mode Exit fullscreen mode
  1. Edge Case Handling: Add window resize listener to refresh observer:
   onMounted(() => {
     window.addEventListener('resize', initializeObserver);
   })
Enter fullscreen mode Exit fullscreen mode

Leveling Up

Want to enhance this further?

  • Scroll Restoration

    Add a scroll position memory system using the History API

  • Virtualization

    Implement windowing for huge datasets

  • Offline 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)

Collapse
 
martinszeltins profile image
Martins

Where's the DEMO?

SurveyJS custom survey software

JavaScript Form Builder UI Component

Generate dynamic JSON-driven forms directly in your JavaScript app (Angular, React, Vue.js, jQuery) with a fully customizable drag-and-drop form builder. Easily integrate with any backend system and retain full ownership over your data, with no user or form submission limits.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay