← Coding round practice

Paginated List with Retry and Offline Cache

HardPaging · RoomConstant memory per page

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.

KotlinConstant memory per page
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") }
                }
        }
    }
}