Introduction
After a decade of Android development, we still see teams spending weeks debating whether to reach for Kotlin Flows vs LiveData in new features. The choice isn't academic—it shapes how your team reads state, handles backpressure, and migrates toward modern architecture. In 2026, the answer is clearer than ever: StateFlow is the modern default for Android development, but LiveData remains viable in specific lifecycle-aware scenarios. This article walks through the trade-offs with production examples so you can make the right call for your codebase.
StateFlow is the modern default
StateFlow emerged as Kotlin's answer to "we need LiveData but without Android-specific baggage." It's a Flow that always holds a current value, emits that value immediately to new collectors, and lives in coroutines. Most teams migrating to modern Kotlin adopt it first.
Why StateFlow wins by default:
- Guaranteed initial value on collection—no null-checking dance.
- Built on standard Kotlin coroutines; works in Android, backend, multiplatform projects.
- Natural fit with MVVM and MVI patterns.
- Part of the kotlinx.coroutines ecosystem, so tooling and libraries assume it.
Here's a typical ViewModel using StateFlow:
class UserProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
try {
val user = userRepository.getUser()
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
sealed class UiState {
data object Loading : UiState()
data class Success(val user: User) : UiState()
data class Error(val message: String) : UiState()
}
}
In a Compose UI, collecting this is trivial:
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel) {
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is Loading -> LoadingIndicator()
is Success -> ProfileCard(user = (uiState as Success).user)
is Error -> ErrorMessage(message = (uiState as Error).message)
}
}
StateFlow doesn't come with lifecycle awareness built in—but you pair it with repeatOnLifecycle in Fragment or Activity code, and Compose handles it automatically. That's the trade-off: one extra line of setup, but far more flexibility downstream.
LiveData is lifecycle-aware but Android-only
LiveData predates Flows by years. It was designed around Android's Activity and Fragment lifecycle—it pauses emission when the UI isn't visible, resumes when it's back, and automatically cleans up when the owner is destroyed. For developers who lived through the reference-leak era, this was revolutionary.
Where LiveData still shines:
- Fragment or Activity code that doesn't use Compose and isn't migrated to coroutines.
- Teams with massive legacy bases where rewriting every observer would take months.
- Direct lifecycle binding—no extra scope management needed.
Here's a LiveData ViewModel doing the same work as our StateFlow example:
class UserProfileViewModelLiveData : ViewModel() {
private val _uiState = MutableLiveData<UiState>(UiState.Loading)
val uiState: LiveData<UiState> = _uiState
init {
viewModelScope.launch {
try {
val user = userRepository.getUser()
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
sealed class UiState {
data object Loading : UiState()
data class Success(val user: User) : UiState()
data class Error(val message: String) : UiState()
}
}
Observing in an Activity or Fragment:
class UserProfileActivity : AppCompatActivity() {
private val viewModel by viewModels<UserProfileViewModelLiveData>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_profile)
viewModel.uiState.observe(this) { uiState ->
when (uiState) {
is Loading -> showLoading()
is Success -> showProfile(uiState.user)
is Error -> showError(uiState.message)
}
}
}
}
The observer is automatically cleaned up when the Activity is destroyed. LiveData never emits while the UI is in the background. For legacy Fragment-based apps, this is still the path of least resistance.
The catch: LiveData is Android-specific. If you're building shared code, testing on the JVM, or planning multiplatform Kotlin, it's a dead end. And lifecycle awareness, while convenient, masks the underlying coroutine scope—debugging suspension or backpressure is harder.
Flows have richer operators
This is where Flows pull ahead. The Kotlin Flow API includes dozens of operators: map, filter, flatMapLatest, combine, distinctUntilChanged, debounce, retry, and more. LiveData has map and switchMap, but that's mostly it. For complex state logic, Flows let you compose transformations cleanly.
Real scenario: A search screen that debounces user input, filters duplicates, hits an API, and combines results with saved searches.
class SearchViewModel(
private val searchRepository: SearchRepository
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
private val savedSearches = searchRepository.getSavedSearches()
val searchResults: StateFlow<SearchState> = searchQuery
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { query ->
if (query.isBlank()) {
flowOf(SearchState.Empty)
} else {
searchRepository.search(query)
.map<List<Result>, SearchState> { SearchState.Results(it) }
.catch { emit(SearchState.Error(it.message ?: "Search failed")) }
}
}
.combine(savedSearches) { results, saved ->
if (results is SearchState.Results) {
results.copy(
saved = saved.filter { it.query.contains(searchQuery.value) }
)
} else {
results
}
}
.stateIn(viewModelScope, SharingStarted.Eagerly, SearchState.Empty)
fun setQuery(query: String) {
searchQuery.value = query
}
sealed class SearchState {
data object Empty : SearchState()
data class Results(val items: List<Result>, val saved: List<SavedSearch> = emptyList()) : SearchState()
data class Error(val message: String) : SearchState()
}
}
Try that with LiveData. You'll end up with nested switchMaps, manual state management, and a ViewModel that's hard to test. Flows handle it in a readable pipeline.
Backpressure handling is another Flow advantage. If your UI is slow to consume or the coroutine is cancelled, Flows respect that. LiveData ignores backpressure—it just keeps emitting to whoever's listening. In high-frequency scenarios (location updates, sensor data, real-time trading feeds), this matters.
Migrate when touching code
The pragmatic approach: don't rewrite your app overnight. When you're fixing a bug, adding a feature, or refactoring a ViewModel, migrate that code to StateFlow and modern Flow operators. Over time, your codebase drifts toward the new standard without a Big Bang rewrite.
Migration checklist:
- Replace MutableLiveData with MutableStateFlow. The API is nearly identical;
valueworks the same way. - Expose as StateFlow, not MutableStateFlow. Use
asStateFlow()to hide mutability outside the ViewModel. - Update observers to collectors. In Fragment/Activity code, use
repeatOnLifecycle(Lifecycle.State.STARTED)to manage the collection scope. - Replace switchMap with flatMapLatest. Semantics are nearly identical.
- Test with runTest and advanceUntilIdle. Flow tests are more explicit than LiveData tests.
Example migration of a Fragment observer:
// Before: LiveData
viewModel.uiState.observe(viewLifecycleOwner) { state ->
render(state)
}
// After: StateFlow with lifecycle awareness
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
It's three extra lines, but now you're in the coroutines ecosystem. Future operators and multiplatform support are open to you.
Common mistakes
- Exposing MutableStateFlow outside the ViewModel. Always return
StateFlow<T>viaasStateFlow(). Otherwise, UI code mutates state unexpectedly, and testing becomes a nightmare. - Forgetting to call stateIn() after flatMapLatest. Without
stateIn, your Flow cold—each new subscriber replays the whole chain. UsestateIn(viewModelScope, SharingStarted.Lazily, initialValue)to share the work. - Mixing lifecycles carelessly. If you collect StateFlow in a scope that outlives the UI (e.g., a long-running background job), you'll keep the ViewModel alive and leak memory. Always collect in
viewModelScopeorrepeatOnLifecycle. - Assuming LiveData's lifecycle protection is free. Under the hood, LiveData still holds references to every observer. If you observe without cleaning up (e.g., in a loop or callback), you'll leak. It's not magic; it's just hidden.
- Testing Flows without runTest. Old-school JUnit tests and Flows don't mix well. Use
runTestandadvanceUntilIdle()to control time and let async work complete.
Key takeaways
- StateFlow is the modern default for Android & Kotlin development in 2026. It's coroutine-native, multiplatform-ready, and works seamlessly with Compose.
- LiveData is still viable for legacy Fragment/Activity codebases. If you're not touching lifecycle code and not migrating soon, keep it. But don't start new projects with it.
- Flows operators are more powerful and composable than LiveData's switchMap and map. For complex state logic (debounce, combine, retry, etc.), Flows shine.
- Migrate incrementally. Don't rewrite the entire app. When you're already in a ViewModel, flip it to StateFlow. Over quarters, you'll reach full adoption without risk.
- Always protect mutability. Expose
StateFlow<T>, neverMutableStateFlow<T>. Expose cold Flows viastateInorshareInto prevent repeated subscriptions. - Collect in the right scope. Use
viewModelScopefor ViewModel collectors andrepeatOnLifecyclefor UI-layer collectors. Misalignment is the root of most leaks and suspension bugs.






Comments
Loading comments…