Recipe: Debounce a search field¶
Problem: a search box should query as the user types, but not fire a request on every keystroke.
Hold the query as state, expose a QueryChanged event, and debounce the query inside a
produceRetainedState block using a snapshot Flow. The debounce and the search live in the
producer, so they’re cancelled and restarted cleanly as the query changes.
@Composable
override fun present(): SearchState {
var query by rememberRetained { mutableStateOf("") }
val results by produceRetainedState<List<Hit>>(emptyList()) {
snapshotFlow { query }
.debounce(300.milliseconds)
.distinctUntilChanged()
.mapLatest { text -> if (text.isBlank()) emptyList() else searchRepository.search(text) }
.collect { hits -> value = hits }
}
return SearchState(query = query, results = results) { event ->
when (event) {
is SearchEvent.QueryChanged -> query = event.text
}
}
}
Key points:
snapshotFlow { query }turns the Compose state into a Flow, sodebounceanddistinctUntilChangedcan operate on it.mapLatestcancels an in-flight search when a newer query arrives.- The whole pipeline is built inside
produceRetainedState, so it isn’t reallocated on recomposition (see observing a Flow).
The UI just reports text changes:
TextField(
value = state.query,
onValueChange = { text -> state.eventSink(SearchEvent.QueryChanged(text)) },
)
See also: Observe a Flow ยท Show loading states