Search-as-you-type with Flow

Flow and StateFlow Mid-level Kotlin 2.0+ API 24+ Reviewed By Best Latest Apps

Pick the fix

Typing 'ca' then 'cat' sometimes shows results for 'ca'. Which change fixes it?

val results: Flow<List<Article>> = query
    .debounce(300)
    .distinctUntilChanged()
    .flatMapMerge { q -> flow { emit(api.search(q)) } }
Options
Hint 1 of 1

What happens to the 'ca' request when 'cat' arrives?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A Replace flatMapMerge with flatMapLatest

Correct. A new query cancels the old request, so its result can't arrive later.

Why not the others?
  • B Increase debounce to 1,000 ms. That makes the race rarer and the app slower; it doesn't remove it.
  • C Add .distinctUntilChanged() after the search. Different results aren't duplicates, so nothing changes.
  • D Collect on Dispatchers.IO. Threading doesn't change which response arrives last.

30-second answer

Keep the query in a StateFlow, then debounce(300) to wait until typing pauses, distinctUntilChanged() to skip repeats, and flatMapLatest { api.search(it) } so a new query cancels the request for the previous one. Only the latest query's result can reach the UI, so a slow old response can't overwrite a newer one.

Practise saying it · 40 s. No recording, just a timer

Explain how you'd build search-as-you-type.

Say your answer out loud, then check which of these points you covered:

  • Query in a StateFlow
  • debounce and distinctUntilChanged
  • flatMapLatest cancels the old request
  • Catch errors inside the inner flow

Deep dive

flatMapLatest cancels the inner flow when a new value arrives; flatMapConcat waits for each to finish; flatMapMerge runs them in parallel.

Wrap the call in flow { emit(api.search(q)) } or use mapLatest when each query returns one value.

Handle errors inside the inner flow with catch, so one failed search doesn't end the whole stream.

Filter out blank queries before the network call and emit an empty result for them.

Common mistakes

  • Using flatMapMerge or map with a launch, so old responses arrive after new ones.
  • Putting catch after flatMapLatest, so the first error kills search for the rest of the session.
  • Debouncing inside the composable instead of the ViewModel.

Senior take & follow-ups

Mention cancellation of the old request, not just ignoring its result. That's what separates seniors.

Follow-up questions

Think of your answer first, then open the hint.

  1. Why does catch placement matter here?

    Show hint

    After flatMapLatest, an error completes the outer flow, so later queries do nothing.

  2. What's the difference between debounce and sample?

    Show hint

    debounce waits for a pause; sample emits the latest value at fixed intervals.

Compare on iOS: Preventing stale search results Combine's switchToLatest plays the role of flatMapLatest.

Suggest a fix if something here is wrong or out of date.