A small Jetpack Compose app that searches a local list only after the user pauses typing, and cancels any in-flight search when a newer query arrives.
Typing into a search box produces a new value on every keystroke (B → Ba → Baj → Baji). Hitting a data source for each of those values is wasteful:
- Most intermediate queries are discarded immediately.
- Slow requests can finish out of order and show stale results.
- The UI feels busy (loading spinners) while the user is still typing.
This app demonstrates how Kotlin Flow solves that with debounce, distinctUntilChanged, and flatMapLatest.
Compose TextField
│
│ onQueryChanged(query)
▼
SearchViewModel
│
│ MutableStateFlow<String> ← query updates instantly (UI stays in sync)
▼
debounce(500) ← wait until typing pauses
│
distinctUntilChanged() ← skip duplicate settled queries
│
flatMapLatest { query -> ← cancel the previous search Flow
Repository.search(query) (including its delay)
}
│
FakeSearchRepository ← delay(2000) + in-memory filter
│
StateFlow<SearchUiState> ← Initial | Loading | Success | Empty | Error
│
▼
Compose UI
Unidirectional data flow: the UI only sends events (onQueryChanged, retry). The ViewModel owns state. The UI never talks to the repository.
ui/
├── SearchScreen.kt ← observes StateFlows, renders one of the UI states
├── SearchViewModel.kt ← query StateFlow + search pipeline
└── SearchUiState.kt ← sealed UI states
data/
├── SearchRepository.kt ← suspend search contract
└── FakeSearchRepository.kt ← static list + simulated latency + log cancellation
debounce(500) emits a query only when no new value arrived for 500 ms.
Without it, B, Ba, Baj, and Baji would each start a search. With it, only Search("Baji") runs after the user pauses.
The TextField itself is not debounced: _query updates on every keystroke so the typed text appears immediately. Only the search waits.
The fake API uses delay(2000) so a search is easy to interrupt. If the user changes the query while a request is in flight:
SEARCH STARTED: Android
SEARCH CANCELLED: Android ← delay() is cancelled; work is not finished
SEARCH STARTED: Kotlin
SEARCH COMPLETED: Kotlin
Without cancellation, Android could still complete after Kotlin and overwrite the UI with the wrong results.
Cancellation is cooperative: delay() checks the coroutine job. flatMapLatest cancels that job when a new query arrives.
t=0ms type "A"
t=80ms type "An"
t=160ms type "And"
t=240ms type "Andr"
t=320ms type "Android"
debounce timer resets on every keystroke
t=820ms 500 ms of silence → emit "Android"
flatMapLatest starts search
UI → Loading
No repository call happens at t=0, 80, 160, 240, or 320. Intermediate queries never leave the ViewModel.
t=820ms Search("Android") starts, delay(2000) begins
t=1200ms user replaces text with "Kotlin"
query StateFlow updates immediately (TextField shows "Kotlin")
debounce timer starts again
t=1700ms 500 ms of silence → emit "Kotlin"
flatMapLatest cancels the Android coroutine
delay(2000) for Android throws CancellationException
Search("Kotlin") starts
t=3700ms Kotlin completes → Success or Empty
Filter Logcat by SearchDebounce to see STARTED / CANCELLED / COMPLETED.
flatMapLatest switches to the new inner Flow and cancels the previous one.
| Operator | Behavior | Wrong for search because |
|---|---|---|
flatMapConcat |
Waits for the previous inner Flow to finish, then runs the next | Stale "Android" results can appear after the user already typed "Kotlin" |
flatMapMerge |
Runs inner Flows concurrently | Completions can arrive out of order |
flatMapLatest |
Cancels the previous inner Flow, runs only the latest | Latest query wins; in-flight work is dropped |
Search is last-write-wins. flatMapLatest is the operator that matches that rule.