Paginated List with Retry and Offline Cache
The problem
Build an infinite-scrolling list backed by a paginated API (page, pageSize).
Requirements:
- Load the next page as the user approaches the bottom.
- Show a footer loader, and a retry row when a page fails — without losing the pages already loaded.
- Opening the app offline must show the last cached page.
- No duplicate requests for the same page.
Stuck? Open one hint
Keep one state object that knows the next page number and whether a load is already running. Every bug in this exercise comes from not having that flag.
Show the worked solution
Reviewers look for three things here.
1. One source of truth. The UI reads from Room; the network writes to Room. That is what makes offline work without a second code path.
2. A guard against duplicate loads. Hold nextPage, isLoading and endReached in the state. loadNext() returns immediately if isLoading or endReached.
3. Error handling that does not destroy progress. A failed page sets an appendError field; the list stays, the footer becomes a retry row, and nextPage is not advanced.
Then name the production answer: Paging 3 with a RemoteMediator does all of this, and you would use it in a real app. Writing it by hand here is to show you understand what it is doing.
data class FeedState(
val items: List<Item> = emptyList(),
val nextPage: Int = 1,
val isLoading: Boolean = false,
val endReached: Boolean = false,
val appendError: String? = null,
)
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
private val _state = MutableStateFlow(FeedState())
val state = _state.asStateFlow()
init {
// cached first, so an offline open still shows something
viewModelScope.launch {
repo.cached().collect { cached ->
if (_state.value.items.isEmpty()) _state.update { it.copy(items = cached) }
}
}
loadNext()
}
fun loadNext() {
val s = _state.value
if (s.isLoading || s.endReached) return // the duplicate-request guard
_state.update { it.copy(isLoading = true, appendError = null) }
viewModelScope.launch {
runCatching { repo.page(s.nextPage) }
.onSuccess { page ->
_state.update {
it.copy(
items = it.items + page.items,
nextPage = it.nextPage + 1,
endReached = page.items.isEmpty(),
isLoading = false,
)
}
}
.onFailure { e ->
// page number not advanced: retry reloads the same page
_state.update { it.copy(isLoading = false, appendError = e.message ?: "Load failed") }
}
}
}
}