DEV Community

Tom Bloom
Tom Bloom

Posted on

Infinite Pagination with Ruby on Rails API and TanStack Query

And with filtering and sorting too...

Building efficient pagination can be tricky, but combining Ruby on Rails with TanStack Query (formerly React Query) makes it surprisingly elegant. In this article, I'll show you how to implement infinite scroll pagination that's both performant and developer-friendly.

The Rails API Setup

Let's start with a Ruby on Rails API that has these characteristics:

  • JWT authentication/authorization in v1/application_controller.rb
  • pagy gem for pagination
  • blueprint gem for JSON serialization
  • has_scope gem for filtering

Here's our v1/groups_controller.rb index endpoint:

class V1::GroupsController < V1::ApplicationController
  before_action :set_group, only: [:show, :update, :destroy]

  has_scope :search

  def index
    sort_by = params[:sort_by] || 'created_at'
    direction = params[:sort_direction] || 'desc'

    @groups = apply_scopes(current_account.groups).order(sort_by => direction)
    @pagy, @groups = pagy(@groups, limit: params[:per_page])

    render json: {
      data: V1::GroupBlueprint.render_as_hash(@groups),
      meta: pagy_metadata(@pagy)
    }
  end

  # rest of the controller...
end
Enter fullscreen mode Exit fullscreen mode

The beauty of this setup is its simplicity. The has_scope gem automatically handles our search filtering, while pagy takes care of pagination metadata.

The TanStack Infinite Query Hook

Now for the frontend magic. Here's our useInfiniteGroups hook:

export const useInfiniteGroups = (params?: {
  per_page?: number;
  search?: string;
  sort_by?: string;
  sort_direction?: string;
  [key: string]: any; // Allow any additional parameters
}) => {
  const api = useApi();

  return useInfiniteQuery({
    queryKey: ['groups'],
    queryFn: async ({ pageParam }) => 
      await api.get<GroupsResponse>('/v1/groups', { 
        ...params, 
        page: pageParam, 
        per_page: params?.per_page || 20 
      }),
    getNextPageParam: (lastPage) => lastPage.meta.next,
    initialPageParam: 1,
  });
};
Enter fullscreen mode Exit fullscreen mode

The hook is beautifully simple:

  • queryKey identifies our query for caching
  • queryFn fetches the data with pagination parameters
  • getNextPageParam tells TanStack Query when there are more pages
  • All filtering parameters are passed through seamlessly

The React Component with Infinite Scroll

Here's where it all comes together in our GroupsList component:

import { Button } from '@/components/ui/button';
import { useInfiniteGroups } from '@/lib/hooks/useGroups';
import { queryClient } from '@/main';
import { Link } from '@tanstack/react-router';
import { PlusIcon } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';

export default function GroupsList() {
  const [search, setSearch] = useState('');
  const [sortBy, setSortBy] = useState('created_at');
  const [sortDirection, setSortDirection] = useState('desc');

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading
  } = useInfiniteGroups({
    search,
    sort_by: sortBy,
    sort_direction: sortDirection
  });

  // Reset infinite query when parameters change
  useEffect(() => {
    queryClient.resetQueries({ queryKey: ['groups'] });
  }, [search, sortBy, sortDirection]);

  const observer = useRef<IntersectionObserver | null>(null);

  const sentinelRef = useCallback((node: HTMLDivElement) => {
    if (isFetchingNextPage) return;
    if (observer.current) observer.current.disconnect();

    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasNextPage) {
        fetchNextPage();
      }
    });

    if (node) observer.current.observe(node);
  }, [isFetchingNextPage, hasNextPage, fetchNextPage]);

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

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

  return (
    <div className="space-y-4">
      <div className="flex justify-between items-center">
        <h1 className="text-2xl font-bold">Groups</h1>
        <Link to='/admin/groups/new'>
          <Button>
            <PlusIcon className='w-4 h-4 mr-2' />
            Add Group
          </Button>
        </Link>
      </div>

      {/* Search and sorting controls */}
      <div className="flex gap-4 p-4 bg-gray-50 rounded-lg">
        <input
          type="text"
          placeholder="Search groups..."
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="flex-1 px-3 py-2 border border-gray-300 rounded-md"
        />

        <select
          value={sortBy}
          onChange={(e) => setSortBy(e.target.value)}
          className="px-3 py-2 border border-gray-300 rounded-md"
        >
          <option value="created_at">Creation Date</option>
          <option value="name">Name</option>
          <option value="updated_at">Last Modified</option>
        </select>

        <select
          value={sortDirection}
          onChange={(e) => setSortDirection(e.target.value)}
          className="px-3 py-2 border border-gray-300 rounded-md"
        >
          <option value="desc">Descending</option>
          <option value="asc">Ascending</option>
        </select>
      </div>

      {/* Groups list */}
      <div className="space-y-4">
        {allGroups.map(group => (
          <div key={group.id} className="p-4 border border-gray-200 rounded-lg">
            <h3 className="text-lg font-semibold">{group.name}</h3>
            <p className="text-gray-600">{group.description}</p>
          </div>
        ))}
      </div>

      {/* Invisible element that triggers loading */}
      <div ref={sentinelRef} style={{ height: '1px' }} />

      {isFetchingNextPage && (
        <div className="text-center py-4">Loading more groups...</div>
      )}

      {hasNextPage && (
        <div className="text-center py-4">
          <Button
            onClick={() => fetchNextPage()}
            disabled={isFetchingNextPage}
            variant="outline"
          >
            {isFetchingNextPage ? 'Loading...' : 'Load More'}
          </Button>
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Magic: Automatic Cache Invalidation

The most elegant part of this solution is how filtering works. Notice this simple useEffect:

useEffect(() => {
  queryClient.resetQueries({ queryKey: ['groups'] });
}, [search, sortBy, sortDirection]);
Enter fullscreen mode Exit fullscreen mode

When any filter parameter changes, we invalidate all cached queries that start with the ['groups'] key. This forces a fresh fetch with the new parameters, and TanStack Query handles all the complexity behind the scenes.

Why This Approach Works So Well

As a Rails lover, I appreciate the simplicity of this solution:

  1. Server-side: Rails with pagy provides robust, efficient pagination out of the box
  2. Client-side: TanStack Query handles caching, background updates, and loading states automatically
  3. Developer Experience: The API is intuitive and the React component is clean and maintainable
  4. Performance: Only fetch what you need, when you need it
  5. Flexibility: Easy to add new filters or sorting options

What's Next?

This example covers the core infinite pagination pattern, but there's much more you can do:

  • Loading states and error handling
  • Optimistic updates for CRUD operations
  • Real-time updates with WebSockets
  • Advanced filtering with multiple parameters
  • Virtualization for extremely large lists

Would you like to see a full CRUD example with authentication, API request generators, TypeScript types, and all the CRUD queries, mutations, and components? Let me know in the comments!


Have you tried this pattern in your Rails + React apps? What challenges have you faced with infinite pagination? Share your experience in the comments below!

Top comments (0)