Paging through a long list
The app shows the first 20 orders and asks for more only when the user scrolls near the bottom, using the cursor from the last page. Three rules keep it correct:
- One page request at a time. Scrolling fires “near the end” many times; only the first should start a request.
- Stop at the end. When the server returns no next cursor, there’s nothing more to load.
- Forget stale requests. If the user pulls to refresh or changes the filter while page 3 is loading, the answer for page 3 belongs to the old list and must be thrown away, not appended to the new one.
On Android, the Paging 3 library implements all of this. On iOS, a small “paginator” object in the view model does it.
Client-side paging state is a small state machine: items, nextCursor, isLoading, endReached, error, plus a generation (or the identity of the query) that increments on refresh and on any parameter change; responses carry the generation they were requested for and are discarded if it no longer matches. Prefetch distance should cover a fast fling (a page or so ahead), deduplication by id protects against server-side overlap, and errors on append should keep existing items and offer a retry footer rather than replace the list. Paging 3 (PagingSource for network-only, RemoteMediator + Room PagingSource for offline-first, where Room is the source of truth and invalidation drives updates) gives this for free on Android, including cancellation on refresh. Server side, cursor pagination on (sortKey, id) with stable sort order is non-negotiable for lists that change.
@Observable @MainActor
final class OrdersPaginator {
private(set) var orders: [Order] = []
private(set) var isLoading = false
private(set) var endReached = false
private(set) var appendFailed = false
private var nextCursor: String?
private var generation = 0
private var loadTask: Task<Void, Never>?
private let api: OrdersAPI
var filter: OrderFilter = .open { didSet { refresh() } }
init(api: OrdersAPI) { self.api = api }
func refresh() {
generation += 1 // older responses become stale
loadTask?.cancel()
orders = []; nextCursor = nil; endReached = false; isLoading = false
loadMore()
}
func loadMoreIfNeeded(currentItem: Order) {
guard let index = orders.firstIndex(where: { $0.id == currentItem.id }),
index >= orders.count - 10 else { return } // about half a page ahead
loadMore()
}
func loadMore() {
guard !isLoading, !endReached else { return } // one request at a time
isLoading = true
appendFailed = false
let requested = generation
loadTask = Task {
defer { if requested == generation { isLoading = false } }
do {
let page = try await api.orders(filter: filter, cursor: nextCursor, limit: 20)
guard requested == generation else { return } // stale: drop it
let known = Set(orders.map(\.id))
orders += page.orders.filter { !known.contains($0.id) }
nextCursor = page.nextCursor
endReached = page.nextCursor == nil
} catch {
if requested == generation, !(error is CancellationError) { appendFailed = true }
}
}
}
}// Android: Paging 3 with Room as the source of truth and a RemoteMediator filling it
@OptIn(ExperimentalPagingApi::class)
class OrdersRemoteMediator(
private val api: OrdersApi,
private val db: AppDatabase,
private val filter: OrderFilter,
) : RemoteMediator<Int, OrderEntity>() {
override suspend fun load(loadType: LoadType, state: PagingState<Int, OrderEntity>): MediatorResult {
val cursor = when (loadType) {
LoadType.REFRESH -> null
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> db.remoteKeys().forFilter(filter)?.nextCursor
?: return MediatorResult.Success(endOfPaginationReached = true)
}
return try {
val page = api.orders(filter, cursor, limit = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.orders().clear(filter)
db.remoteKeys().clear(filter)
}
db.orders().upsertAll(page.orders.map { it.toEntity(filter) })
db.remoteKeys().upsert(RemoteKey(filter, page.nextCursor))
}
MediatorResult.Success(endOfPaginationReached = page.nextCursor == null)
} catch (e: IOException) {
MediatorResult.Error(e)
}
}
}
@HiltViewModel
class OrdersViewModel @Inject constructor(api: OrdersApi, db: AppDatabase) : ViewModel() {
private val filter = MutableStateFlow(OrderFilter.OPEN)
@OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class)
val orders: Flow<PagingData<Order>> = filter.flatMapLatest { f -> // a new filter cancels the old pager
Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 10),
remoteMediator = OrdersRemoteMediator(api, db, f),
pagingSourceFactory = { db.orders().pagingSource(f) },
).flow
}.map { data -> data.map(OrderEntity::toDomain) }
.cachedIn(viewModelScope)
fun setFilter(f: OrderFilter) { filter.value = f }
}// React: the same generation check in a hook
export function useOrders(api: OrdersApi, filter: OrderFilter) {
const [orders, setOrders] = useState<Order[]>([]);
const state = useRef({ cursor: null as string | null, loading: false, end: false, generation: 0 });
useEffect(() => { // filter changed: start a new generation
state.current = { cursor: null, loading: false, end: false, generation: state.current.generation + 1 };
setOrders([]);
void loadMore();
}, [filter]);
const loadMore = useCallback(async () => {
const s = state.current;
if (s.loading || s.end) return; // one request at a time
s.loading = true;
const requested = s.generation;
try {
const page = await api.orders(filter, s.cursor, 20);
if (requested !== state.current.generation) return; // stale: drop it
setOrders((old) => {
const known = new Set(old.map((o) => o.id));
return [...old, ...page.orders.filter((o) => !known.has(o.id))];
});
s.cursor = page.nextCursor;
s.end = page.nextCursor === null;
} finally {
if (requested === state.current.generation) s.loading = false;
}
}, [api, filter]);
return { orders, loadMore };
}generationgoes up on every refresh or filter change. Each request remembers the generation it was started in, and its answer is dropped if the list has moved on.isLoadingblocks a second request while one is running;endReachedstops requests after the last page.- Deduplicating by id protects the list if the server ever returns an item twice.
- On Android,
flatMapLateststarts a freshPagerwhen the filter changes and cancels the old one; theRemoteMediatorwrites pages into Room, and the list shows whatever Room has, so it also works offline.
Tap the line with the bug.
func loadMore() {
guard !isLoading, !endReached else { return }
isLoading = true
Task {
let page = try? await api.orders(filter: filter, cursor: nextCursor, limit: 20)
orders += page?.orders ?? []
nextCursor = page?.nextCursor
isLoading = false
}
}
func setFilter(_ newFilter: OrderFilter) {
filter = newFilter
orders = []; nextCursor = nil; isLoading = false
loadMore()
}fun loadMore() {
if (isLoading || endReached) return
isLoading = true
viewModelScope.launch {
val page = runCatching { api.orders(filter, nextCursor, 20) }.getOrNull()
_orders.update { it + page?.orders.orEmpty() }
nextCursor = page?.nextCursor
isLoading = false
}
}
fun setFilter(newFilter: OrderFilter) {
filter = newFilter
_orders.value = emptyList(); nextCursor = null; isLoading = false
loadMore()
}async function loadMore() {
if (s.loading || s.end) return;
s.loading = true;
const page = await api.orders(s.filter, s.cursor, 20).catch(() => null);
setOrders((old) => [...old, ...(page?.orders ?? [])]);
s.cursor = page?.nextCursor ?? null;
s.loading = false;
}
function setFilter(filter: OrderFilter) {
s.filter = filter;
setOrders([]); s.cursor = null; s.loading = false;
void loadMore();
}Hint: The user scrolls, page 3 of 'All' starts loading, and they switch to 'Open' before it returns. Where does page 3 end up?
setFilter resets the list and starts loading the new filter, but the old request is still running. When page 3 of “All” arrives, it’s appended to the new “Open” list, closed orders and all, and its cursor overwrites the new one, so the next page continues the old query. Resetting isLoading also allowed two requests at once. Nothing crashes; the list is just quietly wrong.
Cancel the old request and tag requests with a generation, so stale answers are dropped:
func setFilter(_ newFilter: OrderFilter) {
filter = newFilter
generation += 1
loadTask?.cancel()
orders = []; nextCursor = nil; endReached = false; isLoading = false
loadMore() // loadMore checks 'requested == generation' before appending
}// With Paging 3 this is flatMapLatest over the filter; by hand:
fun setFilter(newFilter: OrderFilter) {
filter = newFilter
generation++
loadJob?.cancel()
_orders.value = emptyList(); nextCursor = null; endReached = false; isLoading = false
loadMore() // the launched block checks the generation before updating
}function setFilter(filter: OrderFilter) {
s.generation += 1; // loadMore drops responses from older generations
s.filter = filter;
setOrders([]); s.cursor = null; s.end = false; s.loading = false;
void loadMore();
}- Client paging state: items, nextCursor, isLoading, endReached, append error, and a generation bumped on refresh or parameter change; drop responses from older generations and cancel in-flight loads.
- Prefetch roughly a page ahead; dedupe by id; append errors keep items and show a retry footer.
- Android: Paging 3 (
PagingSource,RemoteMediator+ Room for offline-first,flatMapLatestover parameters,cachedIn). - Server: cursor pagination on a stable
(sortKey, id)order.
Check this part
Why must only one page request run at a time?
The filter changes while a page is loading. What should happen to that page when it arrives?
What does a RemoteMediator do in Paging 3?