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 }
}