LiveData vs StateFlow
Introduction
For years, LiveData was the de facto choice for observing state changes in Android UIs. It won developers over because it solved a real problem: UI controllers that were oblivious to lifecycle events would leak memory or crash when the system destroyed them. StateFlow, introduced later as part of Kotlin Coroutines and the kotlinx.coroutines library, solves many of the same problems with a different design philosophy. It is a state-holding observable that also respects structured concurrency and works naturally inside coroutines.
The choice between the two is no longer obvious. This tutorial examines lifecycle awareness, thread safety, composition, and migration strategies side by side. You will see runnable code for both approaches, along with the Gradle and XML wiring you actually need to make them work in a real project. The goal is not to declare a winner but to give you the technical clarity needed to pick the right tool for each scenario.
Lifecycle Awareness
LiveData was built around LifecycleOwner. When you observe a LiveData instance from an activity or fragment, the observation is automatically started and stopped based on the lifecycle state you pass in. If you observe with LifecycleCoroutineScope, the coroutine collecting a StateFlow is automatically cancelled when the owner is destroyed. Both approaches prevent leaks, but they achieve it in different ways.
class UserViewModel : ViewModel() {
private val _name = MutableLiveData<String>()
val name: LiveData<String> = _name
fun updateName(newName: String) {
_name.value = newName
}
}
This ViewModel exposes a read-only LiveData<String> while keeping the mutable driver private. It is written this way because exposing MutableLiveData directly would allow external callers to mutate state outside of view-model logic, which breaks encapsulation and makes unit testing harder. At runtime, every call to updateName posts a value change to any active observers on the main thread.
class MainActivity : AppCompatActivity() {
private lateinit var viewModel: UserViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this)[UserViewModel::class.java]
viewModel.name.observe(this) { name ->
// Update the UI with the new name
}
}
}
In this activity, observe(this) binds the observer to the activity's lifecycle. When the activity moves to the DESTROYED state, the observer is unregistered automatically. The lambda captures only the current name string, so there is no risk of holding onto a stale context or view reference across configuration changes.
class MainFragment : Fragment() {
private var _binding: FragmentMainBinding? = null
private val binding get() = _binding!!
private val viewModel: MainViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.userNameFlow
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.collect { name ->
binding.nameTextView.text = name
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
This fragment uses flowWithLifecycle, which ensures the coroutine collecting StateFlow is active only while the view lifecycle is at least STARTED. Unlike the activity version, StateFlow collection is explicit: the flow only emits to subscribers that are actively collecting. If no collector is attached, the flow still holds its latest value, but it does not trigger recomputation or unnecessary work.
Thread Safety and Observers
LiveData delivers observations on the main thread by default, and posting values from a background thread schedules the update for the next main-thread loop. This behavior is convenient but can mask bugs when developers assumeLiveData operations are thread-safe in all contexts. StateFlow, on the other hand, is designed to be updated from any thread, and it guarantees that collectors always receive values sequentially without races.
class ThemeViewModel : ViewModel() {
private val _isDarkMode = MutableStateFlow(false)
val isDarkMode: StateFlow<Boolean> = _isDarkMode
fun toggleTheme() {
_isDarkMode.value = !_isDarkMode.value
}
}
The ViewModel wraps a MutableStateFlow and exposes only the read-only StateFlow interface. Writing the public API this way prevents callers from calling value = directly, which protects the invariant that theme changes always happen through a controlled toggle method. At runtime, each call to toggleTheme updates the flow's current state atomically and notifies all active collectors.
class SettingsActivity : AppCompatActivity() {
private lateinit var viewModel: ThemeViewModel
private lateinit var switchMaterial: MaterialSwitch
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this)[ThemeViewModel::class.java]
switchMaterial = findViewById(R.id.switch_material)
lifecycleScope.launchWhenStarted {
viewModel.isDarkMode.collect { isDark ->
switchMaterial.isChecked = isDark
}
}
switchMaterial.setOnCheckedChangeListener { _, isChecked ->
if (isChecked != viewModel.isDarkMode.value) {
viewModel.toggleTheme()
}
}
}
}
This activity collects the flow inside lifecycleScope.launchWhenStarted, which starts the coroutine when the activity reaches STARTED and cancels it on STOPPED. The checkbox listener guards against redundant toggles by comparing the new boolean against the current flow state before mutating it. Because StateFlow always holds the latest emitted value, this check is reliable even if the user interacts with the switch faster than the UI thread processes events.
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.example.livedatavsstateflow'
compileSdk 34
defaultConfig {
applicationId "com.example.livedatavsstateflow"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
}
buildFeatures {
viewBinding true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.activity:activity-ktx:1.9.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
}
These dependencies are sufficient for both LiveData and StateFlow examples in this tutorial. The lifecycle-runtime-ktx artifact provides coroutine scope extensions like lifecycleScope and launchWhenStarted. The lifecycle-livedata-ktx variant adds Kotlin-friendly APIs such as observeLifecycle. Note that StateFlow itself is part of kotlinx.coroutines, which is already pulled in through the Android runtime extensions.
Observability Patterns and Default Values
One structural difference between LiveData and StateFlow is how they handle the initial state. A fresh LiveData is null until someone posts a value, which forces you to handle nulls defensively in your UI. StateFlow requires an initial value at construction time, and that value is always available to any late subscriber. This design decision has real implications for how you write your ViewModels and activities.
class ProductViewModel : ViewModel() {
private val _products = MutableStateFlow<List<Product>>(emptyList())
val products: StateFlow<List<Product>> = _products
fun loadProducts() {
viewModelScope.launch {
val result = repository.fetchProducts()
_products.value = result
}
}
}
data class Product(val id: Long, val name: String, val price: Double)
This ViewModel starts with an empty list so that UI components never observe null, and it exposes a read-only flow to callers. The data class is intentionally kept minimal for tutorial clarity, but in production code you would add validation logic and possibly derive display properties from the domain model. When loadProducts runs, the coroutine suspends on the network call and then posts the result back into the flow on the dispatcher that is active at the call site.
class ProductListActivity : AppCompatActivity() {
private lateinit var viewModel: ProductViewModel
private lateinit var recyclerView: RecyclerView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_product_list)
viewModel = ViewModelProvider(this)[ProductViewModel::class.java]
recyclerView = findViewById(R.id.recycler_view)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = ProductAdapter(emptyList()) { /* item click */ }
lifecycleScope.launchWhenStarted {
viewModel.products.collect { productList ->
(recyclerView.adapter as ProductAdapter).submitList(productList)
}
}
findViewById<Button>(R.id.btn_refresh).setOnClickListener {
viewModel.loadProducts()
}
}
}
The activity sets up a simple recycler view and starts collecting the product flow when the activity becomes visible. Because StateFlow always has a value, the adapter receives an empty list immediately and then updates when new data arrives. The refresh button explicitly triggers a network request instead of relying on automatic refresh, which gives the user clear feedback about when data is being reloaded.
Coroutines and Collecting StateFlow
StateFlow is a coroutine primitive, and its natural home is inside structured concurrency scopes. When you collect a StateFlow with collect or catch, you are participating in a coroutine hierarchy that respects cancellation, timeouts, and exception handling. This makes it easier to write robust reactive chains than the callback-based APIs that predate coroutines.
class SearchViewModel : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query
private val _searchResults = MutableStateFlow<List<String>>(emptyList())
val searchResults: StateFlow<List<String>> = _searchResults
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching
fun setQuery(newQuery: String) {
_query.value = newQuery
}
fun performSearch() {
viewModelScope.launch {
_isSearching.value = true
try {
_searchResults.value = repository.search(query.value)
} catch (e: Exception) {
_searchResults.value = emptyList()
} finally {
_isSearching.value = false
}
}
}
}
This ViewModel tracks three pieces of related state: the search query, the results, and a loading indicator. Combining them inside a single viewModelScope launch ensures that the loading flag is always reset, even if the network call throws. Writing the search logic this way keeps the view model synchronous from the caller's perspective while still performing the I/O off the main thread.
class SearchFragment : Fragment() {
private var _binding: FragmentSearchBinding? = null
private val binding get() = _binding!!
private val viewModel: SearchViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentSearchBinding.bind(view)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.searchResults.catch { e ->
binding.statusTextView.text = "Search failed: ${e.message}"
emit(emptyList())
}.collect { results ->
binding.statusTextView.text = if (results.isEmpty()) {
"No results found"
} else {
"${results.size} results"
}
binding.resultListView.adapter = ResultAdapter(results)
}
}
binding.searchButton.setOnClickListener {
viewModel.performSearch()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
The fragment attaches a catch operator to the search results flow so that network failures are surfaced in the UI rather than silently swallowed by a failed coroutine. It then collects the non-failed results and updates both the status label and the list adapter. The binding reference is cleared in onDestroyView to prevent memory leaks from the fragment lifecycle.
Composition and Transforming Multiple Flows
Real applications rarely depend on a single stream of state. StateFlow integrates cleanly with the Kotlin coroutines flow API, which means you can combine, debounce, throttle, and transform multiple sources using standard operators. LiveData has MediatorLiveData and Transformations, but those APIs are less composable and harder to test.
class CartViewModel : ViewModel() {
private val _items = MutableStateFlow<List<CartItem>>(emptyList())
val items: StateFlow<List<CartItem>> = _items
private val _discountCode = MutableStateFlow("")
val discountCode: StateFlow<String> = _discountCode
val discountedTotal: StateFlow<Double> = combine(
items,
discountCode
) { itemList, code ->
val baseTotal = itemList.sumOf { it.price * it.quantity }
val discount = when (code.lowercase()) {
"save10" -> baseTotal * 0.10
"save20" -> baseTotal * 0.20
else -> 0.0
}
(baseTotal - discount).toBigDecimal().setScale(2, RoundingMode.HALF_UP).toDouble()
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = 0.0
)
data class CartItem(val productId: Long, val price: Double, val quantity: Int)
}
This ViewModel merges two separate flows into one derived value using combine. The resulting StateFlow is materialized with stateIn, which turns a cold combined flow into a hot state-holding flow scoped to the view model. The WhileSubscribed policy keeps the internal computation alive for five seconds after the last collector disconnects, which prevents rapid reconnect-disconnect cycles from creating and destroying expensive subscriptions. TheBigDecimal rounding logic ensures the displayed total never contains floating-point drift.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:id="@+id/discount_code_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="Enter discount code"
android:inputType="text"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/total_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintTop_toBottomOf="@id/discount_code_input"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp"
android:text="$0.00" />
</androidx.constraintlayout.widget.ConstraintLayout>
This layout shows a minimal cart screen with a discount code input and a total display. The ConstraintLayout constraints are anchored to the parent edges so the UI remains stable across different screen sizes. In a real project you would bind the input field to a TextWatcher or a compose TextField, but the core point here is that the XML only defines static placeholders; the dynamic behavior comes entirely from the ViewModel flows.
Migrating from LiveData to StateFlow
Migrating a codebase from LiveData to StateFlow is often easier than expected because most of the migration work is mechanical. You replace MutableLiveData with MutableStateFlow, add stateIn or viewModelScope collection points, and convert observe calls into coroutine collect blocks. The bigger challenges are usually architectural: deciding which lifecycle events should restart collection, and whether legacy callbacks should remain supported alongside the new flow-based API.
class LegacyUserProfileViewModel : ViewModel() {
private val _username = MutableLiveData<String>()
val username: LiveData<String> = _username
private val _profileLoaded = MutableLiveData<Boolean>()
val profileLoaded: LiveData<Boolean> = _profileLoaded
fun fetchProfile(userId: String) {
viewModelScope.launch(Dispatchers.IO) {
val result = userRepository.findById(userId)
_username.postValue(result.name)
_profileLoaded.postValue(true)
}
}
}
This is a representative legacy ViewModel that posts values from a background dispatcher using postValue. It is functionally correct, but it mixes threading concerns and exposes mutable state if the class design changes. Migrating this pattern to StateFlow removes the need for postValue entirely, because flow emissions are naturally safe across threads and are collected on the dispatchers that the coroutine builder chooses.
class MigratedUserProfileViewModel : ViewModel() {
private val _username = MutableStateFlow<String?>(null)
val username: StateFlow<String?> = _username.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null
)
private val _profileLoaded = MutableStateFlow(false)
val profileLoaded: StateFlow<Boolean> = _profileLoaded.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false
)
fun fetchProfile(userId: String) {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
userRepository.findById(userId)
}
_username.value = result.name
_profileLoaded.value = true
}
}
}
The migrated ViewModel keeps the same public contract so existing observers can switch from observe to collect without changing their data types. Each flow is materialized with stateIn, which gives the flow a defined lifecycle inside the view model and prevents memory leaks from dangling subscriptions. The withContext call explicitly switches to the IO dispatcher for the network operation while allowing the final value assignment to run on the coroutine's default dispatcher, which in a viewModelScope is typically a background dispatcher but never the main thread unless you use UI or Main.
configurations.all {
resolutionStrategy {
force 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
force 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
}
}
If you have existing dependencies that pull in older coroutine or lifecycle versions transitively, adding a forced resolution strategy can prevent subtle runtime mismatches during migration. The forced versions should match the versions declared in your build.gradle, or you should migrate the declarations instead of masking incompatibilities with force. For most projects, cleaning up the explicit dependency declarations is preferable to forcing versions globally, because forced resolutions make future upgrades harder to reason about.
Common Pitfalls
Even developers who understand both APIs can fall into traps when they mix the paradigms. One frequent mistake is treating StateFlow like LiveData by collecting it inside a short-lived lambda without binding the collection to a lifecycle. Without a proper scope, the coroutine keeps running after the screen is gone and holds references to UI objects or view models that should have been released. Another common error is assuming that updating a StateFlow from any thread is always safe without considering whether downstream processing expects main-thread execution. If you post a flow emission on a background thread and the collector performs UI work, the app may crash or produce race conditions.
A third pitfall appears during migration when developers replace LiveData.observe() with StateFlow.collect() but forget to call stateIn. A raw MutableStateFlow is a cold-to-hot bridge only when materialized; otherwise, late subscribers may miss values or see the flow behave unpredictably if multiple collectors interfere with each other. A fourth mistake is using MediatorLiveData-style logic inside StateFlow collections by manually posting values in response to other events. The flow API already provides operators like combine, zip, and flatMapLatest that express the same relationships declaratively, and mixing imperative posts into flow collectors makes the code harder to test and reason about.
Conclusion
LiveData and StateFlow both solve the problem of observing state changes safely in Android applications, but they arrive at that solution from different architectural directions. LiveData ties observation tightly to lifecycle owners and hides coroutine complexity behind simple observe calls. StateFlow embraces structured concurrency, requires an initial value, and composes naturally with the rest of the Kotlin coroutines ecosystem. In modern Android development, StateFlow is generally the better default for new code, especially when you are already using coroutines or when you need to combine multiple state sources. Legacy codebases that depend heavily on LiveData should migrate incrementally, replacing view models one layer at a time and verifying that each migrated component respects the same cancellation and lifecycle guarantees that LiveData previously provided.