Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 082 / 100

Kotlin flows vs LiveData for Android & Kotlin developers

Which to use in 2026?

Expona Admin7 min read

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:

kotlin
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:

kotlin
@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:

kotlin
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:

kotlin
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.

kotlin
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:

  1. Replace MutableLiveData with MutableStateFlow. The API is nearly identical; value works the same way.
  2. Expose as StateFlow, not MutableStateFlow. Use asStateFlow() to hide mutability outside the ViewModel.
  3. Update observers to collectors. In Fragment/Activity code, use repeatOnLifecycle(Lifecycle.State.STARTED) to manage the collection scope.
  4. Replace switchMap with flatMapLatest. Semantics are nearly identical.
  5. Test with runTest and advanceUntilIdle. Flow tests are more explicit than LiveData tests.

Example migration of a Fragment observer:

kotlin
// 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> via asStateFlow(). 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. Use stateIn(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 viewModelScope or repeatOnLifecycle.
  • 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 runTest and advanceUntilIdle() 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>, never MutableStateFlow<T>. Expose cold Flows via stateIn or shareIn to prevent repeated subscriptions.
  • Collect in the right scope. Use viewModelScope for ViewModel collectors and repeatOnLifecycle for UI-layer collectors. Misalignment is the root of most leaks and suspension bugs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles