How to make instant search from server by using Kotlin Coroutines
Implementing an instant search (also called “search-as-you-type”) from a server using Kotlin Coroutines is a classic use case for coroutines and flows. The key is to debounce user input to avoid flooding the server and cancel previous requests to avoid out-of-order results.
Here’s a step-by-step guide with best practices.
We’ll use a reactive approach with Flow and the MVVM architecture.
-
UI (Fragment/Activity): Collects the search results and sends user input to the ViewModel.
-
ViewModel: Exposes a
StateFlowfor the UI state and contains the logic to trigger the search flow. -
Repository: Contains the suspend function to call the API.
-
DataSource/Retrofit: The actual network interface.
Step 1: Set up the Data Source (Retrofit Service)
Create a Retrofit service with a suspend function.
// Service interface
interface ApiService {
@GET("search")
suspend fun search(
@Query("query") query: String,
@Query("page") page: Int = 1 // Optional: for pagination
): SearchResponse // Your data model class
}
// Data class for the response
data class SearchResponse(
val results: List<SearchResult>,
val totalPages: Int
)
data class SearchResult(val id: String, val title: String)
Step 2: Create the Repository
The repository provides a clean API for the ViewModel to call.
class SearchRepository @Inject constructor(
private val apiService: ApiService,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun searchQuery(query: String, page: Int = 1): Result<SearchResponse> {
return withContext(ioDispatcher) {
try {
val response = apiService.search(query, page)
Result.success(response)
} catch (e: Exception) {
Result.failure(e)
}
}
}
}
// It's better to use a sealed class for Result (see best practices below)
Step 3: Create the ViewModel (The Core Logic)
This is where the magic happens. We will use a MutableStateFlow to capture the query and a callbackFlow to create a stream of queries. We’ll then transform this stream using powerful Flow operators.
@HiltViewModel // Or using your preferred DI
class SearchViewModel @Inject constructor(
private val repository: SearchRepository
) : ViewModel() {
// For one-off events like showing a Snackbar for errors
private val _searchEvent = MutableSharedFlow<UiEvent>()
val searchEvent = _searchEvent.asSharedFlow()
// For the state of the UI (Loading, Success, Error)
private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Empty)
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
// The current search query
private val currentQuery = MutableStateFlow("")
// Function called by the UI (e.g., from a SearchView text listener)
fun onSearchQueryChanged(query: String) {
currentQuery.value = query
}
init {
// Start listening to query changes when the ViewModel is created
observeQueryChanges()
}
private fun observeQueryChanges() {
viewModelScope.launch {
currentQuery
.debounce(300) // Wait 300ms after the last keystroke
.filter { query ->
// Only proceed if query is empty or has more than 2 characters
if (query.isEmpty()) {
_uiState.value = SearchUiState.Empty
return@filter false
}
if (query.length < 3) { // Avoid searching for very short strings
_uiState.value = SearchUiState.ReadyToSearch
return@filter false
}
true
}
.distinctUntilChanged() // Avoid duplicate consecutive queries (e.g., pasting the same text)
.flatMapLatest { query -> // Cancel previous search and start a new one
performSearch(query)
}
.collect() // Collect the results and update UI State
}
}
private fun performSearch(query: String): Flow<SearchUiState> {
return flow {
// Emit Loading state
emit(SearchUiState.Loading)
// Perform the network request
val result = repository.searchQuery(query)
// Emit Success or Error state based on result
when (result) {
is Result.Success -> {
val results = result.data.results
if (results.isEmpty()) {
emit(SearchUiState.EmptyResult)
} else {
emit(SearchUiState.Success(results))
}
}
is Result.Failure -> {
emit(SearchUiState.Error(result.exception.message ?: "Unknown error"))
// Also send the error event for one-off handling (e.g., Snackbar)
_searchEvent.emit(UiEvent.ShowSnackbar("Failed to search: ${result.exception.message}"))
}
}
}.catch { e ->
// Catch any exceptions in the flow itself
emit(SearchUiState.Error(e.message ?: "Unknown error in flow"))
_searchEvent.emit(UiEvent.ShowSnackbar("Search failed"))
}
}
}
// Define the UI State as a sealed class
sealed class SearchUiState {
object Empty : SearchUiState()
object ReadyToSearch : SearchUiState() // Query is >0 but <3 chars
object Loading : SearchUiState()
data class Success(val results: List<SearchResult>) : SearchUiState()
data class Error(val message: String) : SearchUiState()
object EmptyResult : SearchUiState() // Query was valid but returned no results
}
// For one-off events (optional but recommended)
sealed class UiEvent {
data class ShowSnackbar(val message: String) : UiEvent()
}
Step 4: Collect the State in the UI (Fragment)
The UI simply sends query changes to the ViewModel and reacts to state changes.
@AndroidEntryPoint
class SearchFragment : Fragment() {
private var _binding: FragmentSearchBinding? = null
private val binding get() = _binding!!
private val viewModel: SearchViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSearchBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Setup SearchView or EditText listener
binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.onSearchQueryChanged(newText ?: "")
return true
}
})
// Collect the UI State and update the UI
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
when (state) {
is SearchUiState.Empty -> {
binding.progressBar.hide()
binding.recyclerView.hide()
binding.textInfo.text = "Start typing to search..."
}
is SearchUiState.ReadyToSearch -> {
binding.progressBar.hide()
binding.recyclerView.hide()
binding.textInfo.text = "Type more to search..."
}
is SearchUiState.Loading -> {
binding.progressBar.show()
binding.recyclerView.hide()
binding.textInfo.text = "Searching..."
}
is SearchUiState.Success -> {
binding.progressBar.hide()
binding.recyclerView.show()
binding.textInfo.text = ""
// Update your RecyclerView adapter here
adapter.submitList(state.results)
}
is SearchUiState.Error -> {
binding.progressBar.hide()
binding.recyclerView.hide()
binding.textInfo.text = "Error: ${state.message}"
}
is SearchUiState.EmptyResult -> {
binding.progressBar.hide()
binding.recyclerView.hide()
binding.textInfo.text = "No results found."
}
}
}
}
}
// Optional: Collect one-off events (e.g., for Snackbar)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.searchEvent.collect { event ->
when (event) {
is UiEvent.ShowSnackbar -> {
Snackbar.make(binding.root, event.message, Snackbar.LENGTH_LONG).show()
}
}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
Key Concepts to note:
-
debounce(300): This is the most important operator. It waits for 300 milliseconds of inactivity before emitting the latest query. This prevents a new network call on every single keystroke. -
filter{}: Used to ignore empty or very short queries that wouldn’t yield good results, saving network calls. -
distinctUntilChanged(): If the user pastes the same text again, it won’t trigger a duplicate network request. -
flatMapLatest{}: The superstar for cancellation. If a new query is emitted while a previous network request is still running,flatMapLatestcancels the previous request and immediately starts the new one. This ensures results always match the current query, preventing race conditions. -
StateFlowfor UI State: Holds the entire state of the screen (Loading, Success, Error), making UI updates consistent and predictable. -
Lifecycle Awareness: Using
repeatOnLifecycle(Lifecycle.State.STARTED)ensures the Flow collection stops when the view goes to the background, wasting no resources.
This pattern is robust, efficient, and follows modern Android development best practices.
I am a very enthusiastic Android developer to build solid Android apps. I have a keen interest in developing for Android and have published apps to the Google Play Store. I always open to learning new technologies. For any help drop us a line anytime at contact@mobologicplus.com
