If you’ve been building Android apps for a few years, you’ve probably written your fair share of
. For a long time, it was the go-to choice for exposing observable data from a LiveData
ViewModel
to the UI. It solved an important problem: lifecycle awareness.
But the Android world has changed. Kotlin coroutines have become the default for async programming, and along with them, Flow and StateFlow have emerged as powerful, coroutine-native reactive streams. Many developers are now replacing LiveData entirely.
In this article, I’ll explain why the shift is happening, what makes Flow and StateFlow better in modern Android development, and give you a practical, code-focused migration guide that won’t break your existing architecture.
LiveData’s Origin and Limitations
LiveData was introduced back in 2017 as part of Android Architecture Components. At that time:
- Kotlin coroutines were experimental.
- Most apps used callbacks or RxJava for reactive streams.
- We needed something lifecycle-aware to avoid leaks and crashes from background updates.
LiveData solved these problems well for the time, but it has some hard limitations:
- It’s Android-specific (not usable in Kotlin Multiplatform projects).
- It has very few transformation operators (
map
,switchMap
). - Integration with coroutines feels bolted on via adapters.
- You can’t use it directly in non-UI layers without bringing in Android dependencies.
Why Flow and StateFlow are Taking Over
Flow is platform-agnostic
Flow comes from the kotlinx.coroutines
library — meaning it works in Android, server-side Kotlin, desktop apps, and KMP projects. It’s not tied to the Android lifecycle or framework.
Rich operator support
Flow offers powerful operators like map
, filter
, combine
, debounce
, retry
, flatMapLatest
, and more. These allow you to build complex data pipelines with minimal boilerplate.
repository.getUsersFlow()
.debounce(300)
.map { users -> users.filter { it.isActive } }
.flowOn(Dispatchers.IO)
.collect { activeUsers ->
// Update UI
}
Doing this in LiveData would be awkward at best.
Coroutine-native
Flow integrates directly with coroutines:
- You can
collect
it in a coroutine scope. - Context switching is built in (
flowOn
). - Structured concurrency ensures proper cleanup.
LiveData requires a bridge (asLiveData
or liveData {}
) to fit into coroutine-based code.
Lifecycle awareness without coupling
While Flow itself isn’t lifecycle-aware, you can make it so with repeatOnLifecycle
or launchWhenStarted
:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.dataFlow.collect { data ->
render(data)
}
}
}
This cancels the collection automatically when the UI stops, just like LiveData.
Works for hot and cold streams
- Cold streams: Only emit when collected (default Flow behavior).
- Hot streams: Always active, emit latest values (
StateFlow
,SharedFlow
).
LiveData is always “hot” and always keeps the last value.
Why Google is Leaning Toward Flow
Many Jetpack libraries have switched to Flow-first APIs:
- Room: Can return
Flow<T>
directly. - DataStore: Uses Flow for reading values.
- Paging 3: Exposes
Flow<PagingData<T>>
as the default.
The trend is clear — Flow is becoming the reactive backbone of Android development.
StateFlow: The Modern LiveData
For most UI state, the direct replacement for LiveData is StateFlow:
- Always holds a current value (
.value
). - Hot stream — new collectors get the latest value instantly.
- Fully coroutine-native.
With a small helper like repeatOnLifecycle
, you get the same lifecycle safety as LiveData, but with more control and flexibility.
Migration Guide: LiveData → StateFlow
Basic property migration
Before (LiveData):
private val _name = MutableLiveData<String>()
val name: LiveData<String> = _name
After (StateFlow):
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name
Observing in the UI
Before:
viewModel.name.observe(viewLifecycleOwner) { name ->
binding.textView.text = name
}
After:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.name.collect { name ->
binding.textView.text = name
}
}
}
Transformations
map:
val upperName: StateFlow<String> =
name.map { it.uppercase() }
.stateIn(viewModelScope, SharingStarted.Eagerly, "")
switchMap → flatMapLatest
:
val user: StateFlow<User?> =
userId.flatMapLatest { id ->
repository.getUserFlow(id)
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
MediatorLiveData → combine
val combined: StateFlow<Pair<String, Int>> =
combine(name, age) { n, a -> n to a }
.stateIn(viewModelScope, SharingStarted.Eagerly, "" to 0)
SingleLiveEvent → SharedFlow
private val _events = MutableSharedFlow<String>()
val events: SharedFlow<String> = _events
fun sendEvent(msg: String) {
viewModelScope.launch { _events.emit(msg) }
}
UI:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { showSnackbar(it) }
}
}
Best Practices
- Use StateFlow for UI state, SharedFlow for events.
- Wrap mutable flows in immutable
StateFlow
/SharedFlow
when exposing from ViewModel. - Always collect flows inside
repeatOnLifecycle
in UI components to avoid leaks. - For background layers, use Flow freely without lifecycle bindings.
Conclusion
LiveData isn’t “bad” — it still works fine for many apps. But the Android ecosystem has moved on. With coroutines and Flow, you get a unified, powerful, cross-platform reactive framework that covers more cases with less friction.
If you start new projects today, building with Flow and StateFlow from the ground up will keep your architecture modern and future-proof. And if you’re migrating an existing app, the step-by-step transformations above should make it painless.