Back to catalog

infinite-scroll-builder

Implement infinite scroll with Intersection Observer, React Query, virtual scrolling. Load more data on scroll. TanStack Query, SWR. Keywords - infinite scroll, load more, pagination, intersection observer, virtual scroll, tanstack query infinite, lazy loading

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • Updated: 2025-11-22

Download the latest version (v1.0.0) from this page.

SKILL.md

---
name: infinite-scroll-builder
description: Implement infinite scroll with Intersection Observer, React Query, virtual scrolling. Load more data on scroll. TanStack Query, SWR. Keywords - infinite scroll, load more, pagination, intersection observer, virtual scroll, tanstack query infinite, lazy loading
---

# Infinite Scroll Builder

## When to Use This Skill

Activate when:
- Implementing infinite scroll (load more on scroll)
- Building paginated lists that load automatically
- Need virtual scrolling for large lists (10k+ items)
- Want smooth scroll performance
- Building social media feeds or infinite galleries

## What This Skill Does

Implements infinite scroll with:
- Intersection Observer (detect scroll position)
- Automatic loading on scroll
- Loading indicators
- Virtual scrolling for performance (large lists)
- Integration with TanStack Query / SWR
- Error handling and retry

## Supported Technologies

**React**:
- TanStack Query (React Query) - useInfiniteQuery
- SWR - useSWRInfinite
- react-intersection-observer
- react-virtual (virtual scrolling)

**Vue 3**:
- TanStack Query Vue
- VueUse - useInfiniteScroll

## Example: Infinite Scroll with TanStack Query (React)

```tsx
// hooks/useInfiniteUsers.ts
import { useInfiniteQuery } from '@tanstack/react-query';

interface User {
  id: number;
  name: string;
  email: string;
}

interface UsersResponse {
  users: User[];
  nextCursor: number | null;
}

async function fetchUsers({ pageParam = 0 }: { pageParam?: number }): Promise<UsersResponse> {
  const response = await fetch(`/api/users?cursor=${pageParam}&limit=20`);
  return response.json();
}

export function useInfiniteUsers() {
  return useInfiniteQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: 0,
  });
}
```

```tsx
// components/UserList.tsx
import { useInfiniteUsers } from '../hooks/useInfiniteUsers';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';

export function UserList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
  } = useInfiniteUsers();

  // Intersection Observer to trigger loading
  const { ref, inView } = useInView({
    threshold: 0,
  });

  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (isError) {
    return <div>Error loading users</div>;
  }

  return (
    <div className="space-y-4">
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.users.map((user) => (
            <div
              key={user.id}
              className="rounded-lg border p-4 hover:bg-gray-50"
            >
              <h3 className="font-medium">{user.name}</h3>
              <p className="text-sm text-gray-600">{user.email}</p>
            </div>
          ))}
        </div>
      ))}

      {/* Intersection Observer trigger */}
      <div ref={ref} className="py-4 text-center">
        {isFetchingNextPage ? (
          <div>Loading more...</div>
        ) : hasNextPage ? (
          <div className="text-gray-500">Scroll for more</div>
        ) : (
          <div className="text-gray-500">No more users</div>
        )}
      </div>
    </div>
  );
}
```

## Example: Virtual Scrolling (Large Lists)

```tsx
// components/VirtualUserList.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { useInfiniteUsers } from '../hooks/useInfiniteUsers';

export function VirtualUserList() {
  const parentRef = useRef<HTMLDivElement>(null);

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteUsers();

  // Flatten all pages into single array
  const allUsers = data?.pages.flatMap((page) => page.users) ?? [];

  // Virtual scrolling
  const virtualizer = useVirtualizer({
    count: hasNextPage ? allUsers.length + 1 : allUsers.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100, // Estimated height of each item
    overscan: 5, // Number of items to render outside visible area
  });

  // Fetch more when scrolling near bottom
  useEffect(() => {
    const lastItem = virtualizer.getVirtualItems().at(-1);

    if (
      lastItem &&
      lastItem.index >= allUsers.length - 1 &&
      hasNextPage &&
      !isFetchingNextPage
    ) {
      fetchNextPage();
    }
  }, [
    hasNextPage,
    fetchNextPage,
    allUsers.length,
    isFetchingNextPage,
    virtualizer.getVirtualItems(),
  ]);

  return (
    <div
      ref={parentRef}
      className="h-screen overflow-auto"
    >
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const isLoaderRow = virtualItem.index > allUsers.length - 1;
          const user = allUsers[virtualItem.index];

          return (
            <div
              key={virtualItem.index}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${virtualItem.size}px`,
                transform: `translateY(${virtualItem.start}px)`,
              }}
            >
              {isLoaderRow ? (
                hasNextPage ? (
                  <div>Loading more...</div>
                ) : (
                  <div>No more users</div>
                )
              ) : (
                <div className="rounded-lg border p-4">
                  <h3 className="font-medium">{user.name}</h3>
                  <p className="text-sm text-gray-600">{user.email}</p>
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}
```

## Example: Vue 3 Infinite Scroll

```vue
<!-- components/UserList.vue -->
<script setup lang="ts">
import { useInfiniteQuery } from '@tanstack/vue-query';
import { useIntersectionObserver } from '@vueuse/core';
import { ref, computed } from 'vue';

interface User {
  id: number;
  name: string;
  email: string;
}

const loadMoreTrigger = ref<HTMLElement | null>(null);

const {
  data,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
  isLoading,
} = useInfiniteQuery({
  queryKey: ['users'],
  queryFn: async ({ pageParam = 0 }) => {
    const response = await fetch(`/api/users?cursor=${pageParam}&limit=20`);
    return response.json();
  },
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  initialPageParam: 0,
});

// All users from all pages
const allUsers = computed(() => {
  return data.value?.pages.flatMap((page) => page.users) ?? [];
});

// Intersection Observer to load more
useIntersectionObserver(
  loadMoreTrigger,
  ([{ isIntersecting }]) => {
    if (isIntersecting && hasNextPage.value && !isFetchingNextPage.value) {
      fetchNextPage();
    }
  },
  { threshold: 0 }
);
</script>

<template>
  <div class="space-y-4">
    <div v-if="isLoading">Loading...</div>

    <div v-else>
      <div
        v-for="user in allUsers"
        :key="user.id"
        class="rounded-lg border p-4 hover:bg-gray-50"
      >
        <h3 class="font-medium">{{ user.name }}</h3>
        <p class="text-sm text-gray-600">{{ user.email }}</p>
      </div>

      <!-- Load more trigger -->
      <div ref="loadMoreTrigger" class="py-4 text-center">
        <div v-if="isFetchingNextPage">Loading more...</div>
        <div v-else-if="hasNextPage" class="text-gray-500">
          Scroll for more
        </div>
        <div v-else class="text-gray-500">No more users</div>
      </div>
    </div>
  </div>
</template>
```

## Backend: Cursor-based Pagination

```python
# FastAPI cursor-based pagination
from fastapi import APIRouter, Query
from typing import Optional

router = APIRouter()

@router.get("/users")
async def get_users(
    cursor: int = Query(default=0),
    limit: int = Query(default=20, le=100)
):
    """Get paginated users with cursor-based pagination"""

    # Query users after cursor
    users = await db.query(User).filter(User.id > cursor).limit(limit).all()

    # Determine next cursor
    next_cursor = users[-1].id if len(users) == limit else None

    return {
        "users": [user.dict() for user in users],
        "nextCursor": next_cursor
    }
```

## Best Practices

1. **Use cursor-based pagination** - More reliable than offset/limit
2. **Intersection Observer** - Better performance than scroll events
3. **Virtual scrolling** - For lists with 1000+ items
4. **Loading indicators** - Show when fetching next page
5. **Error handling** - Retry button if load fails
6. **Debounce** - Prevent multiple simultaneous fetches
7. **Skeleton loaders** - Better UX than spinners

## Performance Comparison

| List Size | Technique | Performance |
|-----------|-----------|-------------|
| < 100 items | Standard rendering | ✅ Excellent |
| 100-1000 items | Infinite scroll | ✅ Good |
| 1000-10k items | Virtual scrolling | ✅ Good |
| 10k+ items | Virtual scrolling + windowing | ⚠️ Required |

## Common Pitfalls

```tsx
// ❌ BAD: Using scroll events (poor performance)
window.addEventListener('scroll', handleScroll);

// ✅ GOOD: Using Intersection Observer
const { ref, inView } = useInView();

// ❌ BAD: Offset-based pagination (unreliable with inserts/deletes)
/api/users?offset=20&limit=20

// ✅ GOOD: Cursor-based pagination
/api/users?cursor=123&limit=20
```

## Integration with Other Skills

- **api-integration-layer** - Use TanStack Query for infinite scroll
- **loading-states-handler** - Show loading skeletons
- **table-datagrid-builder** - Infinite scroll in tables

---

**Version**: 1.0.0
**Category**: Frontend Extended
**Complexity**: Medium