Advanced Kotlin Flow: Operators and Patterns
Table of Contents
🌊 Beyond basic collect
If you are already using StateFlow and SharedFlow, you are on the right track. But the true power of Kotlin Flow lies in its transformation and combination operators. These allow you to model complex data flows declaratively, eliminating “callback hells” and scattered mutable state variables.
🔀 Combining Flows (Streams)
In Android, it is common for a view to depend on multiple data sources (e.g., user data + settings + network status).
combine vs zip
This is a classic interview question.
zip: Strict Synchronization
Waits for both flows to emit a new value to combine them. It’s like a zipper.
val flowA = flowOf(1, 2, 3)
val flowB = flowOf("A", "B", "C")
flowA.zip(flowB) { a, b -> "$a$b" }
// Emissions: "1A", "2B", "3C"
If flowB takes a while to emit “B”, zip waits. It does not advance.
combine: Latest Known State
Emits a new value every time either flow emits, using the latest known value of the other.
val userFlow = repository.getUser() // Emits User
val settingsFlow = repository.getSettings() // Emits Settings
userFlow.combine(settingsFlow) { user, settings ->
ProfileUiState(user, settings)
}.collect { state -> updateUi(state) }
If settingsFlow changes, the state is re-calculated using the last emitted user. This is ideal for UI State.
⚡ Flattening Operators
When a Flow emits other Flows (e.g., a search that triggers a network request for each keystroke), you need to “flatten” them.
The King: flatMapLatest
It is the most useful operator for search in Android. When a new emission arrives (new letter typed), it cancels the previous flow (previous search) and starts the new one.
searchQueryStateFlow
.debounce(300) // Wait 300ms of inactivity
.flatMapLatest { query ->
if (query.isEmpty()) {
flowOf(emptyList())
} else {
apiService.searchProducts(query).asFlow()
}
}
.collect { results -> showResults(results) }
This saves bandwidth and ensures you only show the results of the latest search.
Other Flavors
flatMapConcat: Processes sequentially. Waits for flow A to finish before starting B.flatMapMerge: Processes in parallel. Watch out for order!
🛡️ Robust Error Handling
In Flow, exceptions propagate downstream.
flow {
emit(1)
throw RuntimeException("Boom")
}
.catch { e ->
// Catches the exception from above
emit(-1) // We can emit an error value or fallback
}
.collect { ... }
Important: catch only catches errors that occur upstream. It does not catch errors occurring in collect or downstream operators.
⏱️ Timing Operators
debounce(ms): Filters rapid emissions. Vital for SearchViews and buttons (to avoid double clicks).sample(ms): Takes a sample every X time. Useful for very fast UI updates (e.g., download progress) to not saturate the Main Thread.
🎯 Pattern: Partial MVI with scan
The scan operator is like reduce, but emits every intermediate step. It is perfect for handling cumulative state (Redux-style).
sealed class Intent {
data class Add(val n: Int) : Intent()
data class Subtract(val n: Int) : Intent()
}
intentFlow
.scan(0) { total, intent ->
when (intent) {
is Intent.Add -> total + intent.n
is Intent.Subtract -> total - intent.n
}
}
.collect { total -> render(total) }
🧠 Conclusion
Mastering Flow operators allows you to write complex business logic that is:
- Declarative: You say what to do, not how.
- Reactive: The UI always reflects the current state.
- Efficient: Automatic cancellation and backpressure handling.
Don’t stick with just collect. Explore the toolbox.
You might also be interested in
StateFlow vs. SharedFlow: A Practical Guide
When to use which? Hot streams in Kotlin Coroutines. How to prevent event loss and ensure UI consistency.
Kotlin Coroutines: The Android Guide
Mastering Kotlin Coroutines on Android. Dispatchers, structured concurrency, and best practices for asynchronous programming.
Clean Architecture: The Ultimate Guide for Modern Android
Demystifying Clean Architecture: A deep dive into layers, dependencies, and data flow to build indestructible Android apps.