Advanced Pagination Patterns: Bi-Directional Scroll
Beyond simple infinite scroll lies advanced pagination: bidirectional scroll (load up and down), cursor chains for real-time data, and handling edge cases like insertions and deletions. These patterns power complex UIs like chat threads, messaging apps, and collaborative documents where users scroll both forward and backward through time.
Bidirectional pagination is sophisticated but essential for applications where the dataset's middle is the starting point, not the beginning or end.
Bidirectional Infinite Scroll with useInfiniteQuery
React Query supports bidirectional queries via getPreviousPageParam. Load pages both forward and backward:
import { useInfiniteQuery } from '@tanstack/react-query';
async function fetchMessages(cursor = null, direction = 'next') {
const params = new URLSearchParams({
limit: '20',
direction,
});
if (cursor) params.append('cursor', cursor);
const response = await fetch(`/api/messages?${params}`);
return response.json();
}
export function BidirectionalScroll() {
const {
data = { pages: [] },
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
} = useInfiniteQuery({
queryKey: ['messages'],
queryFn: ({ pageParam = null, direction = 'next' }) =>
fetchMessages(pageParam, direction),
getNextPageParam: (lastPage) => lastPage.nextCursor || undefined,
getPreviousPageParam: (firstPage) => firstPage.prevCursor || undefined,
initialPageParam: null,
});
const messages = data.pages.flatMap((page) => page.items);
return (
`<div style={{ height: '600px', overflowY: 'auto' }}>`
{hasPreviousPage && (
`<button onClick={() => fetchPreviousPage()}>`
Load older messages
`</button>`
)}
`<ul>`
{messages.map((msg) => (
`<li key={msg.id}>{msg.text}`</li>`
))}
`</ul>`
{hasNextPage && (
`<button onClick={() => fetchNextPage()}>`
Load newer messages
`</button>`
)}
`</div>`
);
}
The backend must return both nextCursor (for loading forward) and prevCursor (for loading backward). This is nontrivial: you need index references in both directions.
Handling Real-Time Updates in Paginated Lists
When data changes (a message is deleted, a post is edited), your cursor references become stale. Strategies include:
1. Invalidate the entire query: Refetch all pages. Simple but expensive.
const queryClient = useQueryClient();
// When data changes:
queryClient.invalidateQueries({ queryKey: ['messages'] });
2. Update items in place: Modify the cache without refetching.
const queryClient = useQueryClient();
const updateMessage = (id, newText) => {
queryClient.setQueryData(['messages'], (oldData) => ({
...oldData,
pages: oldData.pages.map((page) => ({
...page,
items: page.items.map((item) =>
item.id === id ? { ...item, text: newText } : item
),
})),
}));
};
3. Realtime subscriptions: Use WebSockets or Server-Sent Events (SSE) to push changes, then update cache.
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
export function useLiveMessages(messageId) {
const queryClient = useQueryClient();
useEffect(() => {
const eventSource = new EventSource(
`/api/messages/${messageId}/subscribe`
);
eventSource.addEventListener('update', (event) => {
const change = JSON.parse(event.data);
queryClient.setQueryData(['messages', messageId], (old) => {
// Apply change to cache
return applyChange(old, change);
});
});
return () => eventSource.close();
}, [messageId, queryClient]);
}
export function MessageThread({ messageId }) {
useLiveMessages(messageId);
const { data = [] } = useQuery({
queryKey: ['messages', messageId],
queryFn: () => fetch(`/api/messages/${messageId}`).then((r) => r.json()),
});
return `<ul>{data.map((msg) => `<li>{msg.text}`</li>`)}`</ul>`;
}
This pattern is powerful: the initial query fetches old messages, then SSE pushes new ones real-time.
Handling Insertions: Deduplicating New Messages
If a new message arrives via WebSocket while the user is viewing page 1, append it without duplicates:
const addNewMessage = (message) => {
queryClient.setQueryData(['messages'], (oldData) => {
if (!oldData) return { pages: [{ items: [message], nextCursor: null }] };
// Check if message already exists
const exists = oldData.pages.some((page) =>
page.items.some((item) => item.id === message.id)
);
if (exists) return oldData; // Don't duplicate
// Prepend to first page
const firstPage = oldData.pages[0];
return {
...oldData,
pages: [
{
...firstPage,
items: [message, ...firstPage.items],
},
...oldData.pages.slice(1),
],
};
});
};
Cursor Chains: Handling Data Shifts
In real-time systems, items may be inserted between pages. A common approach is cursor chains: each cursor encodes the previous cursor, creating a chain backward:
// API returns:
{
items: [msg1, msg2, msg3],
nextCursor: "cursor_3_chain_abc", // Includes the previous cursor in the token
prevCursor: "cursor_prev_xyz"
}
The backend ensures that even if new items are inserted, the cursor chain remains valid. This is complex but solves phantom records and skipped items.
Memory Management for Long Lists
Infinite scroll can accumulate thousands of items in memory. React Query allows you to limit cache:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 5 * 60 * 1000, // 5 minutes (previously cacheTime)
},
},
});
// Or per query:
useInfiniteQuery({
queryKey: ['messages'],
queryFn: fetchMessages,
gcTime: 1 * 60 * 1000, // 1 minute
});
When the query is unused for gcTime, it's garbage-collected. For chat threads, 1–5 minutes is typical.
Alternatively, limit the number of pages kept:
const { data = { pages: [] } } = useInfiniteQuery({
queryKey: ['messages'],
queryFn: fetchMessages,
select: (data) => {
// Keep only last 10 pages
return {
...data,
pages: data.pages.slice(-10),
};
},
});
Comparison: Pagination Patterns
| Pattern | Bi-Directional | Real-Time | Memory | Complexity |
|---|---|---|---|---|
| Simple offset | No | Poor | Low | Low |
| Cursor | Yes | Fair | Medium | Medium |
| Cursor chains | Yes | Good | Medium | High |
| Cursor + SSE | Yes | Excellent | High | High |
Choose based on your data characteristics. For chat, use cursor chains + SSE. For feeds, simple cursor is enough.
Key Takeaways
- Bidirectional scroll requires
getPreviousPageParamandfetchPreviousPage(). - Handle real-time updates via invalidation, cache updates, or WebSocket subscriptions.
- Use cursor chains to avoid phantom records when data shifts between requests.
- Limit cache size to prevent memory bloat in long-lived applications.
- Test edge cases: empty results, single item, deletions, concurrent updates.
Frequently Asked Questions
What if the user scrolls to the very beginning or end?
Return undefined from getNextPageParam or getPreviousPageParam when there's no previous/next page. Disable the respective button.
How do I know if a cursor is stale?
The backend returns an error (400 or 410) if the cursor no longer exists. Catch this and invalidate the query, refetching from a known-good cursor.
Can I combine pagination with filtering?
Yes, but filter must be part of the cursor or sent with every request. If you change filters, reset pagination to page 1.
How do I scroll to a message without knowing its offset?
With cursor pagination, you can't jump directly. Use useQuery on the message ID to find its page, then fetch that page.