Kotlin Coroutines have become a core part of Android development. Whether you’re building UI with Jetpack Compose or maintaining legacy XML screens, coroutines help you handle asynchronous work—such as network calls, database queries, and real-time events—in a safe, clear, and efficient way.
However, using coroutines in UI code is trickier than it looks. Incorrect dispatcher usage, collecting flows at the wrong lifecycle moment, or launching tasks that outlive the UI can cause:
- UI freezes
- memory leaks
- navigation bugs
- wrong state updates
- wasted network calls
This article explores several practical UI patterns, showing how to correctly integrate coroutines with:
- Jetpack Compose
- Legacy UI (XML + ViewBinding)
- long-running tasks
- loading and error states
- Dispatchers.Main handling
- real-time search with Flow & debounce
Each section includes examples with explanations aimed at intermediate-level Android developers.
Using Coroutines with Compose
Jetpack Compose is declarative, meaning the UI reacts to state changes. Coroutines fit perfectly into this model because they allow the ViewModel to produce asynchronous state updates while the UI simply observes them.
1. ViewModel exposes StateFlow
Compose works best with StateFlow, because it always exposes the latest UI state.
data class UiState(
val loading: Boolean = false,
val results: List<String> = emptyList(),
val error: String? = null
)
class MainViewModel(
private val repo: Repository
) : ViewModel() {
private val _state = MutableStateFlow(UiState())
val state = _state.asStateFlow()
fun loadData() {
viewModelScope.launch {
_state.update { it.copy(loading = true) }
try {
val data = repo.fetchItems()
_state.update { UiState(loading = false, results = data) }
} catch (e: Exception) {
_state.update { UiState(error = e.message) }
}
}
}
}
Explanation
updateensures atomic state updatesUiStateis immutable → Compose re-renders correctly- Exceptions are caught so UI doesn’t crash
- ViewModel launches work on viewModelScope, safe from configuration changes
2. Compose collects StateFlow
Compose provides collectAsState(), a lifecycle-aware collector.
@Composable
fun MainScreen(viewModel: MainViewModel) {
val uiState by viewModel.state.collectAsState()
when {
uiState.loading -> LoadingView()
uiState.error != null -> ErrorView(uiState.error)
else -> ResultsList(uiState.results)
}
}
Why this works
- Compose starts and stops collection automatically
- No leaks
- UI updates immediately when ViewModel changes state
- Runs on Dispatchers.Main without blocking
Compose is designed to work with coroutines, making it the most seamless coroutine UI environment today.
Using Coroutines with Legacy UI (XML + ViewBinding)
Compose isn’t everywhere yet, and many teams still maintain XML-based screens.
Legacy UI must integrate coroutines carefully to avoid leaks.
1. Collect StateFlow with repeatOnLifecycle
The correct modern pattern:
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
render(state)
}
}
}
Why repeatOnLifecycle is needed
Collectors inside lifecycleScope.launch run forever unless manually cancelled. This causes:
- background updates when UI isn’t visible
- wasted network calls
- memory leaks
repeatOnLifecycle automatically cancels and restarts collectors:
| UI State | Collection |
|---|---|
| Visible (STARTED) | active |
| Background | suspended/cancelled |
| Returned | restarted |
2. Handling configuration changes
Because ViewModel survives configuration changes:
- API calls should live in ViewModel
- UI just observes the current state
This avoids restarting network calls on screen rotation.
3. Don’t launch coroutines inside adapter or views
Bad:
holder.button.setOnClickListener {
lifecycleScope.launch { ... } // wrong place
}
Adapters should emit events back to ViewModel; they should not launch their own coroutines.
Handling Long-Running Tasks
Coroutines excel at async work, but long tasks require caution.
1. Delay vs CPU-heavy work
Suspending:
delay(2000)
— releases the thread, safe on Main.
CPU-bound work:
for (i in 1..1_000_000_000) { ... }
— blocks the thread, freezing UI.
Always dispatch CPU work to Default
viewModelScope.launch {
val result = withContext(Dispatchers.Default) {
heavyComputation()
}
_state.value = UiState(results = result)
}
Explanation
withContextswitches context ONLY inside the block- Does not launch a new coroutine
- Suspends ViewModel coroutine safely
- UI never freezes
2. Handling cancellation
If the user leaves screen:
viewModelScopecancels automaticallywithContextcancels the CPU task- loops must check cancellation manually:
for (i in items) {
ensureActive() // throws CancellationException
process(i)
}
This ensures no “zombie” tasks continue running.
Loading States & Error States
A robust Android UI typically displays:
- Loading state
- Success state
- Empty state
- Error state
- Retry state
Coroutines make state-driven UI clean.
1. Use sealed classes or UiState models
Minimal example:
sealed class ScreenState {
object Loading : ScreenState()
data class Success(val data: List<String>) : ScreenState()
data class Error(val message: String) : ScreenState()
}
ViewModel:
val _state = MutableStateFlow<ScreenState>(ScreenState.Loading)
val state = _state.asStateFlow()
UI:
when (uiState) {
is ScreenState.Loading -> showLoading()
is ScreenState.Success -> showList(uiState.data)
is ScreenState.Error -> showError(uiState.message)
}
Why this works
- Compose re-renders automatically
- XML screens update cleanly
- States prevent illegal UI conditions (e.g., loading + data + error simultaneously)
Correct Usage of Dispatchers.Main
Mistake many beginners make:
Dispatchers.Main.launch { heavyWork() } // WRONG
This freezes UI because coroutine blocks.
Correct usage:
- UI updates → Main
- business logic → Default or IO
Correct pattern:
viewModelScope.launch {
val data = withContext(Dispatchers.IO) {
api.load()
}
_state.value = UiState(data = data) // Main thread
}
Why this is safe
withContext(Dispatchers.IO)does blocking I/O work- UI state update is lightweight → stays on Main
- Structured concurrency keeps everything clean
Example: Search-as-You-Type with Flow Debounce
This is a common UI pattern:
- User types in search bar
- Do NOT fire request on every keystroke
- Wait for user to pause typing
- Cancel previous search when user types again
- Update UI with results
Flow makes this pattern easy.
Step 1: Expose query as MutableStateFlow
class SearchViewModel(private val api: SearchApi) : ViewModel() {
val query = MutableStateFlow("")
private val _results = MutableStateFlow<List<String>>(emptyList())
val results = _results.asStateFlow()
init {
observeQueries()
}
Step 2: Observe with debounce, distinctUntilChanged, and latest search
private fun observeQueries() {
viewModelScope.launch {
query
.debounce(300) // wait 300ms after user stops typing
.distinctUntilChanged() // ignore same query
.filter { it.length >= 2 } // ignore short queries
.flatMapLatest { q ->
flow {
emit(api.search(q)) // emit list from network
}
}
.catch { e ->
_results.value = emptyList() // fail silently for this example
}
.collect { list ->
_results.value = list
}
}
}
Detailed explanation
| Operator | Function |
|---|---|
| debounce(300) | suspends until user finishes typing (reduces API spam) |
| distinctUntilChanged() | only search again if text changed |
| filter {} | early exit for short queries |
| flatMapLatest | cancels old ongoing search when user types again |
| catch | handles exceptions without crashing ViewModel |
| collect | updates UI results |
The key operator here is flatMapLatest because it ensures:
If user types quickly, old searches are cancelled automatically.
This improves responsiveness and avoids wasteful network calls.
Step 3: UI Layer — Compose or XML
Compose:
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
val results by viewModel.results.collectAsState()
Column {
TextField(
value = viewModel.query.collectAsState().value,
onValueChange = { viewModel.query.value = it }
)
LazyColumn {
items(results) { item ->
Text(item)
}
}
}
}
XML ViewBinding:
searchView.addTextChangedListener {
viewModel.query.value = it.toString()
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.results.collect { list ->
adapter.submitList(list)
}
}
}
Putting It All Together: A Full Practical Pattern
In modern Android:
- ViewModel handles logic
- UI collects state safely
- Dispatchers are chosen carefully
- Long work is moved off Main
- Search operations are debounced
Here’s a compact summary architecture:
User Input → StateFlow(query)
↓
Debounce → Distinct → flatMapLatest API search
↓
UI StateFlow(results)
↓
Compose/XML observes with collectAsState / repeatOnLifecycle
This combination:
- avoids leaks
- avoids UI freezes
- avoids excessive network calls
- handles lifecycle automatically
- respects structured concurrency
Conclusion
Coroutines shine in Android UI development when applied correctly. With Compose or legacy XML, coroutines help you write clean, reactive, lifecycle-aware, and efficient UI logic.
You learned:
- How to use coroutines in Compose
- How to use coroutines in XML-based UI safely
- Correct handling of long-running tasks
- Managing loading and error states with StateFlow
- When and how to use Dispatchers.Main
- Implementing search-as-you-type with Flow debounce
Key takeaways
- Compose + StateFlow = reactive UI made simple
repeatOnLifecycleprevents leaked Flow collectorsviewModelScopehandles long-running operations safely- Never block Dispatchers.Main
- debounce + flatMapLatest = perfect real-time search
- Always follow structured concurrency rules
