← Coding round practice

Build a Search Screen with Debounce and Cancellation

MediumCompose · FlowOne in-flight request at a time

The problem

Build a search screen. As the user types, show matching results from an API.

Requirements:

  • Do not fire a request on every keystroke.
  • When a new query arrives, the previous request must be cancelled — a slow old response must never overwrite a newer one.
  • Show loading, empty and error states, and keep the typed query across rotation.
Stuck? Open one hint

The whole problem is one operator choice. Which Flow operator cancels the previous inner flow when a new value arrives?

Show the worked solution

This question is checking whether you know that a race condition is the default here, not an edge case.

Model the query as a MutableStateFlow, then:

  • debounce(300) — wait for the user to pause.
  • filter / distinctUntilChanged — skip blanks and repeated queries.
  • flatMapLatest — the key operator: it cancels the in-flight search when a new query arrives.

Then talk about the parts most candidates skip: the empty-query state should clear results rather than search for everything, errors need a retry, and the query belongs in SavedStateHandle so rotation does not wipe it.

KotlinOne in-flight request at a time
class SearchViewModel(private val repo: SearchRepository) : ViewModel() {

    private val query = MutableStateFlow("")

    @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
    val state: StateFlow<SearchUiState> = query
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { q ->                        // cancels the previous search
            if (q.isBlank()) flowOf(SearchUiState.Idle)
            else flow {
                emit(SearchUiState.Loading)
                emit(SearchUiState.Success(repo.search(q)))
            }.catch { emit(SearchUiState.Error(it.message ?: "Something went wrong")) }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchUiState.Idle)

    fun onQueryChange(value: String) { query.value = value }
}