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
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,
});
};
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>
);
}
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]);
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:
- Server-side: Rails with pagy provides robust, efficient pagination out of the box
- Client-side: TanStack Query handles caching, background updates, and loading states automatically
- Developer Experience: The API is intuitive and the React component is clean and maintainable
- Performance: Only fetch what you need, when you need it
- 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)