Infinite scroll sering dipilih untuk halaman katalog produk, feed, atau daftar posting karena memberi pengalaman navigasi yang terasa mulus: pengguna cukup terus menggulir halaman, dan data berikutnya dimuat otomatis. Namun pada aplikasi berbasis Laravel + Inertia.js, implementasinya tidak cukup hanya dengan menambahkan event scroll lalu memanggil endpoint baru. Jika dilakukan sembarangan, URL bisa tidak sinkron dengan filter aktif, state daftar bisa hilang saat navigasi ke halaman detail lalu kembali, dan jumlah request bisa membengkak.

Di artikel ini kita akan membahas pendekatan yang lebih aman dan terstruktur untuk membuat infinite scroll pada stack Laravel dan Inertia.js. Fokusnya bukan sekadar “bisa jalan”, tetapi tetap menjaga query parameter, state, dan pengalaman navigasi agar tidak terasa rusak bagi pengguna.

Mengapa Infinite Scroll Berbeda dari Pagination Biasa?

Pada pagination tradisional, pengguna berpindah dari halaman 1 ke halaman 2, 3, dan seterusnya. Keunggulan pendekatan ini adalah:

  • URL jelas, misalnya ?page=3&category=shoes.
  • Mudah dibagikan ke pengguna lain.
  • Lebih ramah SEO, karena crawler dapat mengikuti struktur halaman dengan lebih mudah.
  • Beban rendering lebih terkontrol, karena hanya satu halaman data yang aktif.

Sementara itu, infinite scroll lebih menekankan kontinuitas interaksi. Pengguna tidak perlu menekan tombol “Next”, tetapi data baru otomatis ditambahkan ke daftar saat mendekati bagian bawah. Ini cocok untuk:

  • Feed artikel atau posting.
  • Katalog produk yang ingin didorong untuk eksplorasi.
  • Daftar item dengan perilaku konsumsi linear.

Meski nyaman, infinite scroll memiliki beberapa tantangan teknis:

  • URL harus tetap merepresentasikan state saat ini, terutama jika ada filter dan sorting.
  • Data lama jangan tertimpa saat memuat halaman berikutnya; hasil baru harus di-append.
  • State perlu dipertahankan saat pengguna membuka detail item lalu kembali ke daftar.
  • Performa DOM bisa menurun jika item yang dirender terlalu banyak.

Karena itu, strategi terbaik biasanya adalah tetap memakai pagination di backend, lalu membuat perilaku loading di frontend terasa seperti infinite scroll.

Arsitektur yang Direkomendasikan

Pendekatan yang paling stabil pada Laravel + Inertia.js adalah:

  1. Backend tetap mengembalikan data terpaginasikan dengan paginate() atau simplePaginate().
  2. Filter, sorting, dan halaman aktif dibaca dari query string request.
  3. Frontend Inertia melakukan request halaman berikutnya menggunakan query parameter yang sama.
  4. Hasil halaman baru di-append ke state lokal, bukan menggantikan seluruh daftar.
  5. Gunakan Intersection Observer untuk mendeteksi kapan sentinel di bawah list masuk viewport.
  6. Gunakan preserveState dan preserveScroll agar navigasi terasa konsisten.

Dengan pola ini, backend tetap sederhana dan dapat diuji seperti pagination biasa, sedangkan frontend menangani pengalaman scrolling yang progresif.

Implementasi Backend di Laravel

Misalkan kita punya halaman katalog produk. Controller menerima filter kategori, kata kunci pencarian, sorting, dan halaman.

use App\Models\Product;
use Illuminate\Http\Request;
use Inertia\Inertia;

class ProductCatalogController
{
    public function index(Request $request)
    {
        $filters = $request->validate([
            'search' => ['nullable', 'string', 'max:100'],
            'category' => ['nullable', 'string', 'max:50'],
            'sort' => ['nullable', 'in:latest,price_asc,price_desc'],
            'page' => ['nullable', 'integer', 'min:1'],
        ]);

        $query = Product::query()
            ->select(['id', 'name', 'slug', 'price', 'thumbnail', 'created_at'])
            ->when($request->filled('search'), function ($q) use ($request) {
                $q->where('name', 'like', '%' . $request->search . '%');
            })
            ->when($request->filled('category'), function ($q) use ($request) {
                $q->where('category_slug', $request->category);
            });

        match ($request->get('sort')) {
            'price_asc' => $query->orderBy('price'),
            'price_desc' => $query->orderByDesc('price'),
            default => $query->latest(),
        };

        $products = $query
            ->paginate(12)
            ->withQueryString();

        return Inertia::render('Products/Index', [
            'filters' => [
                'search' => $request->string('search')->toString(),
                'category' => $request->string('category')->toString(),
                'sort' => $request->string('sort')->toString(),
            ],
            'products' => $products,
        ]);
    }
}

Ada beberapa hal penting di sini:

  • withQueryString() memastikan link pagination tetap membawa filter yang sedang aktif.
  • Validasi request menjaga query parameter tetap bersih dan mencegah input aneh.
  • Ambil kolom seperlunya agar payload Inertia tidak terlalu besar.

Jika kebutuhan Anda hanya tombol “load more” atau infinite scroll sederhana tanpa total halaman akurat, simplePaginate() bisa menjadi pilihan yang lebih ringan karena tidak melakukan query total count. Namun jika Anda perlu informasi halaman lengkap atau total item, paginate() tetap lebih cocok.

Implementasi Frontend Inertia.js dengan Intersection Observer

Di sisi frontend, kita akan memulai dari props awal dari server, lalu menyimpan item dalam state lokal. Ketika halaman berikutnya dimuat, item baru ditambahkan ke array lama.

Contoh berikut menggunakan Vue dengan Inertia.js, tetapi konsepnya tetap sama pada React atau Svelte.

<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { router, usePage } from '@inertiajs/vue3'

const page = usePage()

const items = ref([...page.props.products.data])
const nextPage = ref(page.props.products.current_page + 1)
const hasMore = ref(page.props.products.current_page < page.props.products.last_page)
const loading = ref(false)
const sentinel = ref(null)

const filters = ref({
  search: page.props.filters.search || '',
  category: page.props.filters.category || '',
  sort: page.props.filters.sort || '',
})

function loadMore() {
  if (loading.value || !hasMore.value) return

  loading.value = true

  router.get(route('products.index'), {
    ...filters.value,
    page: nextPage.value,
  }, {
    preserveState: true,
    preserveScroll: true,
    only: ['products'],
    replace: true,
    onSuccess: (response) => {
      const paginated = response.props.products

      items.value = [...items.value, ...paginated.data]
      nextPage.value = paginated.current_page + 1
      hasMore.value = paginated.current_page < paginated.last_page
    },
    onFinish: () => {
      loading.value = false
    }
  })
}

let observer

onMounted(() => {
  observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) {
      loadMore()
    }
  }, {
    root: null,
    rootMargin: '200px',
    threshold: 0,
  })

  if (sentinel.value) {
    observer.observe(sentinel.value)
  }
})

onBeforeUnmount(() => {
  if (observer) observer.disconnect()
})
</script>

<template>
  <div>
    <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
      <article v-for="product in items" :key="product.id">
        <img :src="product.thumbnail" :alt="product.name">
        <h2>{{ product.name }}</h2>
        <p>{{ product.price }}</p>
      </article>
    </div>

    <div ref="sentinel" class="h-10" />

    <p v-if="loading">Memuat data...</p>
    <p v-else-if="!hasMore">Semua data sudah ditampilkan.</p>
  </div>
</template>

Mengapa memakai Intersection Observer dan bukan event scroll biasa?

  • Lebih efisien karena browser mengelola observasi elemen target.
  • Tidak perlu menghitung posisi scroll secara manual pada setiap frame.
  • Lebih mudah diatur dengan rootMargin agar prefetch terjadi sebelum pengguna benar-benar mencapai bawah halaman.

rootMargin: '200px' berarti request akan dipicu sedikit lebih awal, sehingga pengguna tidak terlalu sering melihat jeda loading.

Menjaga Query Parameter Filter Tetap Konsisten

Kesalahan umum pada infinite scroll adalah memuat halaman 2, 3, dan seterusnya tanpa membawa filter aktif. Akibatnya, hasil yang di-append tidak sesuai dengan pencarian atau kategori yang sedang dipilih.

Karena itu, setiap request harus selalu mengirim kombinasi filter yang aktif, misalnya:

{
  search: 'sepatu',
  category: 'running',
  sort: 'price_asc',
  page: 3
}

Saat pengguna mengganti filter, jangan append ke data lama. Reset daftar dari awal dan kembalikan halaman ke 1.

function applyFilters() {
  router.get(route('products.index'), {
    ...filters.value,
    page: 1,
  }, {
    preserveState: true,
    replace: true,
    onSuccess: (response) => {
      const paginated = response.props.products
      items.value = paginated.data
      nextPage.value = paginated.current_page + 1
      hasMore.value = paginated.current_page < paginated.last_page
    }
  })
}

replace: true berguna agar perubahan filter atau load halaman berikutnya tidak memenuhi history browser dengan terlalu banyak entry kecil. Ini penting untuk menjaga tombol back tetap masuk akal.

Jika Anda ingin URL mencerminkan filter saat ini, tetap gunakan query string. Ini membantu saat halaman direfresh, dibagikan, atau dibuka ulang pada tab baru.

Preserve State saat Navigasi Kembali

Skenario yang paling sering merusak pengalaman pengguna adalah:

  1. Pengguna scroll sampai item ke-60.
  2. Pengguna membuka detail salah satu produk.
  3. Pengguna menekan tombol back.
  4. Daftar kembali ke kondisi awal, scroll reset, dan item yang sudah dimuat hilang.

Di Inertia.js, ada dua konsep yang relevan:

  • preserveState: menjaga state komponen saat melakukan kunjungan tertentu.
  • preserveScroll: menjaga posisi scroll.

Namun ada hal penting: jika state daftar item hanya dihasilkan ulang dari props awal tanpa mekanisme persistensi, Anda tetap bisa kehilangan hasil append sebelumnya pada kondisi tertentu. Karena itu, ada beberapa strategi:

  • Simpan item hasil append di state komponen dan pastikan kunjungan terkait menggunakan preserveState.
  • Gunakan query parameter yang cukup untuk merekonstruksi halaman terakhir jika perlu.
  • Untuk kasus kompleks, pertimbangkan menyimpan state daftar ke store global atau mekanisme remembered state agar saat kembali dari halaman detail, daftar tidak perlu dimuat ulang dari nol.

Pendekatan minimal yang sering cukup efektif adalah: gunakan URL untuk menyimpan filter, gunakan state lokal untuk append item, dan aktifkan preserve state/scroll pada navigasi internal yang relevan. Jika aplikasi Anda memiliki pola eksplorasi panjang seperti marketplace atau media feed, persistensi state yang lebih eksplisit biasanya lebih aman.

Tips praktis: uji selalu alur “scroll jauh → buka detail → back → lanjut scroll”. Banyak implementasi infinite scroll terlihat benar saat demo awal, tetapi gagal pada alur ini.

Trade-off Performa dan SEO

Performa

Infinite scroll bukan berarti selalu lebih cepat. Bahkan, jika semua item terus dirender ke DOM, performa frontend bisa turun seiring panjangnya daftar. Beberapa trade-off yang perlu diperhatikan:

  • Payload bertambah setiap request append.
  • DOM makin besar, terutama jika setiap item memiliki gambar, badge, atau komponen interaktif.
  • Memori browser meningkat pada sesi panjang.

Untuk mengurangi dampak tersebut:

  • Ambil kolom data seperlunya dari database.
  • Gunakan ukuran halaman yang wajar, misalnya 12–24 item.
  • Lazy-load gambar.
  • Pertimbangkan virtualized list jika jumlah item sangat besar dan sesi scroll panjang.
  • Gunakan only pada request Inertia agar hanya prop yang diperlukan yang diperbarui.

SEO

Dibanding pagination tradisional, infinite scroll cenderung kurang ideal untuk SEO. Mesin pencari umumnya lebih mudah memahami halaman yang memiliki struktur URL jelas per halaman. Jika halaman katalog atau blog Anda penting untuk diindeks, pertimbangkan salah satu pendekatan berikut:

  • Gunakan pagination tradisional untuk halaman publik yang berorientasi SEO.
  • Gunakan infinite scroll hanya sebagai enhancement di sisi klien.
  • Sediakan fallback tombol “Load more” atau link pagination yang tetap dapat diakses.

Untuk katalog internal, dashboard, atau area aplikasi yang tidak berfokus pada crawlability, infinite scroll biasanya tidak masalah. Tetapi untuk halaman listing artikel publik, pagination tradisional sering tetap lebih aman.

Kesalahan Umum dan Tips Debugging

  • Data duplikat muncul: biasanya karena observer memicu request berkali-kali sebelum request sebelumnya selesai. Solusinya, lindungi dengan flag loading.
  • Filter tidak konsisten: pastikan semua request halaman berikutnya selalu membawa query yang sama.
  • State hilang saat back: periksa apakah navigasi memakai preserveState, apakah state append disimpan lokal, dan apakah URL cukup merepresentasikan kondisi halaman.
  • URL berubah tetapi daftar tidak sinkron: reset item saat filter berubah, jangan append hasil baru ke filter lama.
  • Request terlalu besar: cek prop Inertia yang dikirim. Gunakan only agar tidak mengirim ulang data yang tidak dibutuhkan.

Debugging paling efektif biasanya dilakukan dengan memeriksa tiga hal sekaligus:

  1. Request network: apakah parameter page, search, category, dan sort benar?
  2. Response pagination: apakah current_page, last_page, dan data sesuai ekspektasi?
  3. State frontend: apakah item lama ditimpa, di-reset, atau di-append dengan benar?

Kapan Memilih Infinite Scroll, Kapan Tetap Pagination?

Pilih infinite scroll jika:

  • pengguna cenderung mengeksplorasi banyak item secara berurutan,
  • tujuan utama adalah engagement atau browsing cepat,
  • SEO bukan prioritas utama untuk halaman tersebut.

Pilih pagination tradisional jika:

  • halaman perlu mudah dibagikan dan dirujuk ke nomor halaman tertentu,
  • SEO listing penting,
  • pengguna sering ingin kembali ke posisi yang terukur, misalnya halaman 5 hasil pencarian.

Pada banyak kasus, solusi terbaik justru hibrida: backend tetap pagination normal, frontend menampilkan pengalaman infinite scroll, dan URL tetap membawa filter yang relevan. Dengan begitu, Anda mendapatkan pengalaman pengguna yang baik tanpa mengorbankan struktur data dan navigasi aplikasi.

Penutup

Infinite scroll pada Laravel dan Inertia.js sebaiknya tidak dibangun sebagai “hack” di atas list biasa. Gunakan pagination backend yang rapi, kirim query parameter filter secara konsisten, manfaatkan Intersection Observer untuk pemicu loading yang efisien, dan atur preserve state/scroll agar alur kembali ke daftar tetap nyaman.

Intinya, jangan korbankan URL dan state demi efek scroll yang terlihat modern. Implementasi yang baik adalah yang tetap dapat dipahami, di-debug, dan dirawat dalam jangka panjang. Jika dilakukan dengan benar, infinite scroll bisa memberi pengalaman yang halus tanpa merusak perilaku dasar aplikasi web.