LiveData: The Original Lifecycle-Aware Observable
LiveData was introduced in 2017 to solve Activity/Fragment lifecycle management. It automatically stops delivering updates when the observer is stopped, preventing crashes from updating destroyed UI.
// LiveData in ViewModel
class UserViewModel : ViewModel() {
private val _user = MutableLiveData<User>()
val user: LiveData<User> = _user
private val _isLoading = MutableLiveData<Boolean>(false)
val isLoading: LiveData<Boolean> = _isLoading
fun loadUser(id: String) {
_isLoading.value = true
viewModelScope.launch {
val result = userRepository.getUser(id)
_user.value = result.getOrNull()
_isLoading.value = false
}
}
}
// Activity/Fragment observes
viewModel.user.observe(viewLifecycleOwner) { user ->
binding.tvName.text = user.name
}
LiveData Limitations
- Not idiomatic Kotlin — predates coroutines and Kotlin Flow
- No backpressure — value just gets replaced; can't buffer
- Always on the main thread — can't emit from background easily
- No operator support —
Transformations.map/switchMapis clunky vs Flow operators - Event handling is broken by default — late subscribers get the last value (sticky behavior)
- Java-based — heavier than StateFlow for Kotlin codebases
StateFlow: LiveData's Kotlin Successor
StateFlow is a Flow subtype that always holds a current value and replays it to new collectors. It's the Kotlin-native replacement for LiveData in ViewModels.
class UserViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UserUiState())
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
userRepository.getUser(id)
.onSuccess { user ->
_uiState.update { it.copy(isLoading = false, user = user) }
}
.onFailure { error ->
_uiState.update { it.copy(isLoading = false, error = error.message) }
}
}
}
}
StateFlow Key Behaviors
Equality deduplication: StateFlow uses equals() to compare new values. If the new value equals the current value, no emission occurs. This prevents redundant re-renders but can surprise you with reference types.
// This will NOT emit a second time — same value
_uiState.value = UserUiState(isLoading = true) // emits
_uiState.value = UserUiState(isLoading = true) // NOT emitted — equal!
// This WILL always emit — mutable list reference changes
_listState.value = mutableListOf("A", "B")
_listState.value = mutableListOf("A", "B") // emits — different reference
Must use repeatOnLifecycle: Unlike LiveData, Flow does not automatically stop collection when UI is backgrounded. Collecting in lifecycleScope.launch {} alone will continue in the background, wasting resources and potentially crashing.
// WRONG — continues collecting when app is in background
lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}
// CORRECT — stops when STARTED, resumes when STARTED again
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
SharedFlow: For Events and Broadcasts
SharedFlow is a hot flow with no initial value. It's ideal for one-shot events (navigation, Snackbars) and broadcasting to multiple collectors.
class EventViewModel : ViewModel() {
// replay = 0: no replay for late collectors (good for events)
// extraBufferCapacity = 1: don't drop events if collector is slow
private val _events = MutableSharedFlow<UiEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
fun onLoginSuccess() {
viewModelScope.launch {
_events.emit(UiEvent.NavigateToHome)
}
}
}
sealed interface UiEvent {
object NavigateToHome : UiEvent
data class ShowError(val message: String) : UiEvent
}
Channel vs SharedFlow for Events
// Channel approach (simpler, recommended for single consumer)
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
// Emit:
viewModelScope.launch { _events.send(UiEvent.NavigateToHome) }
// SharedFlow approach (for multiple consumers or complex buffering)
private val _events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1)
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
For most Android apps, Channel is simpler and covers 90% of event use cases.
The Event Anti-Pattern with StateFlow
A common mistake: storing one-shot events in StateFlow. New collectors (e.g., after rotation) will receive the last event and trigger it again.
// WRONG — event in StateFlow gets re-triggered on rotation
data class UiState(
val navigateToDetail: Boolean = false, // anti-pattern!
val showError: String? = null
)
// Fragment collects uiState; on rotation:
// 1. New Fragment instance is created
// 2. New collector subscribes to StateFlow
// 3. StateFlow replays last value: navigateToDetail = true
// 4. Fragment navigates again — BUG!
// CORRECT — events in Channel, state in StateFlow
class CorrectViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
}
The stateIn() Operator
stateIn() converts a cold Flow into a StateFlow. This is useful for converting repository Flows into ViewModel state.
@HiltViewModel
class UserListViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
// Converts the repository Flow to StateFlow, handling loading/error states
val users: StateFlow<UiState<List<User>>> =
userRepository.observeUsers()
.map<List<User>, UiState<List<User>>> { UiState.Success(it) }
.catch { emit(UiState.Error(it.message ?: "Error")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // stays alive 5s after last subscriber
initialValue = UiState.Loading
)
}
SharingStarted.WhileSubscribed(5_000) is the recommended default — it keeps the upstream Flow alive for 5 seconds after the last subscriber disappears, covering rotation without restarting network requests.
Migration Guide: LiveData → StateFlow
// Before: LiveData
private val _name = MutableLiveData<String>()
val name: LiveData<String> = _name
// After: StateFlow
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name.asStateFlow()
// Before: observe in Fragment
viewModel.name.observe(viewLifecycleOwner) { binding.tvName.text = it }
// After: collect in Fragment
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.name.collect { binding.tvName.text = it }
}
}
// Before: Transformations
val upperName: LiveData<String> = Transformations.map(_name) { it.uppercase() }
// After: Flow operators
val upperName: StateFlow<String> = _name
.map { it.uppercase() }
.stateIn(viewModelScope, SharingStarted.Eagerly, "")
Feature Comparison
| Feature | LiveData | StateFlow | SharedFlow |
|---|---|---|---|
| Current value holder | Yes | Yes | No (unless replay > 0) |
| Lifecycle awareness | Built-in | Manual (repeatOnLifecycle) | Manual |
| Equality dedup | No | Yes | No |
| Kotlin operators | Limited | Full Flow operators | Full Flow operators |
| Threading | Main thread | Any thread | Any thread |
| Backpressure | No | Latest-wins | Configurable |
| Multiple collectors | Yes | Yes | Yes |
| Event delivery | Sticky (bad for events) | Sticky (bad for events) | Configurable (good for events) |
| Android dependency | Yes (androidx.lifecycle) | No (kotlinx.coroutines) | No |
Key Takeaways
| Concept | Summary |
|---|---|
| LiveData | Lifecycle-aware but limited; Java-based; avoid in new code |
| StateFlow | LiveData replacement; requires repeatOnLifecycle; equality dedup |
| SharedFlow | Multi-consumer broadcast; configurable replay and buffer |
| Channel | Simple one-shot event delivery; receiveAsFlow() for collection |
| Event anti-pattern | Never put one-shot events in StateFlow — they re-trigger on rotation |
stateIn() | Converts repository Flow to StateFlow; use WhileSubscribed(5_000) |