Infinite Scroll Implementation

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1212
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    815

Implementing Infinite Scroll on a Website

Infinite scroll replaces pagination with continuous data loading when reaching the bottom of a container. The approach is suitable for content feeds, product catalogs, media galleries — where users consume content linearly and don't need navigation to specific pages.

How It Works

Two main ways to track loading moment:

IntersectionObserver — modern and performant. Browser notifies when sentinel element (empty div at list end) enters visible zone. Doesn't overload event stream.

scroll event — outdated method. Requires throttle/debounce, scrollTop + clientHeight >= scrollHeight calculation. Avoid on mobile.

React Implementation

// hooks/useInfiniteScroll.ts
import { useEffect, useRef, useCallback } from 'react'

interface UseInfiniteScrollOptions {
  onLoadMore: () => void
  hasMore: boolean
  isLoading: boolean
  threshold?: number // px from edge when loading triggers
}

export function useInfiniteScroll({
  onLoadMore,
  hasMore,
  isLoading,
  threshold = 200,
}: UseInfiniteScrollOptions) {
  const sentinelRef = useRef<HTMLDivElement>(null)

  const handleIntersect = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const [entry] = entries
      if (entry.isIntersecting && hasMore && !isLoading) {
        onLoadMore()
      }
    },
    [onLoadMore, hasMore, isLoading]
  )

  useEffect(() => {
    const sentinel = sentinelRef.current
    if (!sentinel) return

    const observer = new IntersectionObserver(handleIntersect, {
      rootMargin: `${threshold}px`,
    })

    observer.observe(sentinel)
    return () => observer.disconnect()
  }, [handleIntersect, threshold])

  return sentinelRef
}
// components/ProductList.tsx
import { useState, useCallback } from 'react'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'

interface Product {
  id: number
  title: string
  image: string
}

interface Page {
  data: Product[]
  nextCursor: string | null
}

async function fetchProducts(cursor: string | null): Promise<Page> {
  const params = cursor ? `?cursor=${cursor}` : ''
  const res = await fetch(`/api/products${params}`)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

export function ProductList() {
  const [items, setItems] = useState<Product[]>([])
  const [cursor, setCursor] = useState<string | null>(null)
  const [hasMore, setHasMore] = useState(true)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const loadMore = useCallback(async () => {
    if (isLoading || !hasMore) return
    setIsLoading(true)
    setError(null)
    try {
      const page = await fetchProducts(cursor)
      setItems(prev => [...prev, ...page.data])
      setCursor(page.nextCursor)
      setHasMore(page.nextCursor !== null)
    } catch (e) {
      setError('Loading error. Try again.')
    } finally {
      setIsLoading(false)
    }
  }, [cursor, hasMore, isLoading])

  // Load first page on mount
  // useEffect(() => { loadMore() }, []) — omitted for brevity

  const sentinelRef = useInfiniteScroll({ onLoadMore: loadMore, hasMore, isLoading })

  return (
    <div>
      <ul className="grid grid-cols-3 gap-4">
        {items.map(p => (
          <li key={p.id}>
            <img src={p.image} alt={p.title} loading="lazy" />
            <span>{p.title}</span>
          </li>
        ))}
      </ul>

      {error && (
        <button onClick={loadMore} className="mt-4 btn-retry">
          {error}
        </button>
      )}

      {isLoading && <Spinner />}

      {/* Sentinel — invisible trigger */}
      <div ref={sentinelRef} aria-hidden="true" />

      {!hasMore && <p className="text-center text-muted">All products loaded</p>}
    </div>
  )
}

Server Side: Cursor-Based Pagination

Offset pagination (LIMIT 20 OFFSET 100) breaks when new records are added — user gets duplicates or skips elements. Cursor-based solves it:

-- First request
SELECT id, title, image, created_at
FROM products
WHERE is_active = true
ORDER BY created_at DESC, id DESC
LIMIT 21; -- +1 to know if there's next page

-- Next request (cursor = base64(created_at + ':' + id))
SELECT id, title, image, created_at
FROM products
WHERE is_active = true
  AND (created_at, id) < ('2024-11-15 10:30:00', 4821)
ORDER BY created_at DESC, id DESC
LIMIT 21;
// Laravel controller
public function index(Request $request): JsonResponse
{
    $limit = 20;
    $cursor = $request->input('cursor');

    $query = Product::where('is_active', true)
        ->orderByDesc('created_at')
        ->orderByDesc('id');

    if ($cursor) {
        [$date, $id] = explode(':', base64_decode($cursor));
        $query->where(function ($q) use ($date, $id) {
            $q->where('created_at', '<', $date)
              ->orWhere(function ($q2) use ($date, $id) {
                  $q2->where('created_at', $date)->where('id', '<', $id);
              });
        });
    }

    $items = $query->limit($limit + 1)->get();
    $hasMore = $items->count() > $limit;
    $data = $items->take($limit);

    $nextCursor = $hasMore
        ? base64_encode($data->last()->created_at . ':' . $data->last()->id)
        : null;

    return response()->json([
        'data' => ProductResource::collection($data),
        'nextCursor' => $nextCursor,
    ]);
}

React Query / TanStack Query

For production, use useInfiniteQuery — it handles caching, deduplication, background refresh:

import { useInfiniteQuery } from '@tanstack/react-query'

function useProducts() {
  return useInfiniteQuery({
    queryKey: ['products'],
    queryFn: ({ pageParam }) => fetchProducts(pageParam ?? null),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: null,
  })
}

// In component:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useProducts()
const items = data?.pages.flatMap(p => p.data) ?? []

Virtualization for Large Lists

With hundreds of items in DOM, performance drops. Solution — virtual scroll: render only visible range.

import { useVirtualizer } from '@tanstack/react-virtual'

function VirtualProductList({ items }: { items: Product[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200, // card height in px
    overscan: 5,
  })

  return (
    <div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: virtualItem.start,
              width: '100%',
            }}
          >
            <ProductCard product={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

Accessibility and UX

Pure infinite scroll creates issues: user can't reach footer, loses position after navigation. Practical solutions:

  • "Load more" button instead of auto-trigger — user controls loading
  • Save scroll position in URL or sessionStorage on navigation
  • Aria-live region to notify screen readers of new items
  • "To top" button after 3+ page loads
// Save position
useEffect(() => {
  const saved = sessionStorage.getItem('scroll-products')
  if (saved) window.scrollTo(0, parseInt(saved))
  return () => {
    sessionStorage.setItem('scroll-products', String(window.scrollY))
  }
}, [])

Timeline

Basic IntersectionObserver + API endpoint — 1 day. With cursor pagination on backend, React Query, virtualization, and position recovery — 3–4 days.