Implementing Infinite Scroll in Mobile Application
Infinite scroll implemented in every second app and broken in roughly half of them. Duplicate requests on list end, spinner showing forever after network error, or list jumping back when adding new items — all follow from same technical mistakes.
Main Problem: Multiple onEndReached Calls
FlatList.onEndReached in React Native fires not once — it can trigger multiple times in quick scroll, on first render if content less than screen, on component height change. Protection:
const isLoadingMore = useRef(false);
const handleEndReached = useCallback(() => {
if (isLoadingMore.current || !hasNextPage) return;
isLoadingMore.current = true;
fetchNextPage().finally(() => {
isLoadingMore.current = false;
});
}, [hasNextPage, fetchNextPage]);
useRef instead of useState because useState doesn't update between two synchronous onEndReached calls.
onEndReachedThreshold={0.3} — start loading when 30% remains to end. At 0.1 user sees spinner before data loads. At 0.5 data preloads too early, wasting traffic.
Cursor-based vs Offset Pagination
Offset-based (?page=2&limit=20) breaks with parallel new elements: page 2 shifts and user either skips items or sees duplicates. Cursor-based (?after=cursor_value) stable — each request starts exactly from last received item.
If API returns only offset — implement client-side deduplication by id:
const uniqueItems = [...existingItems, ...newItems].filter(
(item, index, arr) => arr.findIndex(i => i.id === item.id) === index
);
On Flutter — ScrollController with addListener:
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
});
Alternative — package:infinite_scroll_pagination (pub.dev). Manages pagination state, errors and retry from box. PagingController + PagedListView — minimal boilerplate.
On Android with Compose — LazyListState.firstVisibleItemScrollOffset + derivedStateOf:
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisibleIndex >= items.size - 5 && !isLoading
}
}
derivedStateOf guarantees recomposition only when shouldLoadMore truly changes, not on every scroll pixel.
Handling States
Infinite scroll requires correct handling of four states:
| State | UI |
|---|---|
| Initial load | Skeleton list (not center spinner) |
| Loading next page | Footer with CircularProgressIndicator |
| Error loading next page | Footer with error text + Retry button |
| All data loaded | Footer "No more items" or nothing |
Footer added as ListFooterComponent in FlatList or via itemCount: items.length + (state != DONE ? 1 : 0) in Compose.
From practice: social app, Flutter. Feed of 1000+ posts. Complaints about duplicate posts. Turned out: fast scroll triggered _loadMore() 3–4 times in parallel before first response. Cursor didn't update — every request went with one cursor. Added bool _isLoading flag + early return — duplicates disappeared.
Scroll to Top
On new items in realtime feed, don't force jump to new posts. Show floating button "N new posts" — user decides when to scroll up. scrollToIndex(0) via listRef in RN or animateScrollTo(0) in Flutter.
What's Included
- Infinite scroll with cursor-based or offset pagination
- Protection from multiple requests
- Footer: spinner / error with retry / end of list
- Skeleton-load for first page
- Client-side deduplication if needed
- "New items" button for realtime feeds
Timeline
1–3 business days depending on item complexity and error handling requirements. Cost calculated individually.







