Introduction
Kotlin StateFlow is a hot flow that always replays its current value to new collectors. However, StateFlow uses equality comparison to determine whether to emit a new value — if you assign the same value (by equals() comparison), downstream collectors receive nothing. Additionally, when collecting StateFlow in Android UI with lifecycle-aware APIs, emissions during the STOPPED state are missed. Confusion between StateFlow (always has a value, replays to new collectors) and SharedFlow (configurable replay, may have no value) leads to missing data in collectors.
Symptoms
- Collector does not receive the initial value
- Updating StateFlow with same value does not trigger recomposition
- UI does not update after configuration change (rotation)
collectAsStateshows stale data- StateFlow emission lost when app is in background
- Multiple collectors receive different values
Common Causes
- New value equals current value (StateFlow deduplicates by equality)
- Collector started after emission and StateFlow was updated before collection
- Using
SharedFlowinstead ofStateFlow(no replay by default) - Lifecycle-aware collection skips emissions when lifecycle is not STARTED
- MutableStateFlow updated from multiple threads without proper synchronization
Step-by-Step Fix
- 1.Understand StateFlow value deduplication:
- 2.```kotlin
- 3.// StateFlow deduplicates by equals() comparison
- 4.val _state = MutableStateFlow(UserData("John", 30))
// This does NOT emit - value is the same _state.value = UserData("John", 30) // Collectors receive nothing
// This DOES emit - value is different _state.value = UserData("John", 31) // Collectors receive the new value
// If you need to emit the same value, use a wrapper data class Event<out T>(val content: T, val id: UUID = UUID.randomUUID())
val _state = MutableStateFlow(Event(UserData("John", 30)))
// Each Event is unique because of the UUID _state.value = Event(UserData("John", 30)) // Collectors receive it because the Event instances are different
// OR use SharedFlow for one-time events private val _events = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1) val events: SharedFlow<UiEvent> = _events
fun showToast(message: String) { _events.tryEmit(UiEvent.ShowToast(message)) } ```
- 1.Use SharedFlow when StateFlow semantics do not fit:
- 2.```kotlin
- 3.// StateFlow: always has a value, deduplicates, replays to new collectors
- 4.val stateFlow = MutableStateFlow(0) // Initial value REQUIRED
// SharedFlow: no initial value, configurable replay, no deduplication val sharedFlow = MutableSharedFlow<Int>( replay = 0, // New collectors get nothing until new emission extraBufferCapacity = 10 // Buffer 10 emissions )
// SharedFlow with replay = 1 (like StateFlow but no deduplication) val replayFlow = MutableSharedFlow<Int>( replay = 1, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST )
// Emit the same value multiple times replayFlow.tryEmit(42) replayFlow.tryEmit(42) // Both emissions are delivered (no deduplication) replayFlow.tryEmit(42) // All three are delivered
// Use SharedFlow for one-time events (navigation, toasts, snackbar) class NavigationEvent { private val _navigation = MutableSharedFlow<NavEvent>(extraBufferCapacity = 1) val navigation: SharedFlow<NavEvent> = _navigation
fun navigateTo(destination: String) { _navigation.tryEmit(NavEvent.Navigate(destination)) } } ```
- 1.**Collect StateFlow with lifecycle awareness in Android":
- 2.```kotlin
- 3.// WRONG - collectAsState collects even when lifecycle is DESTROYED
- 4.@Composable
- 5.fun UserScreen(viewModel: UserViewModel) {
- 6.val state by viewModel.state.collectAsState() // May collect in wrong lifecycle
- 7.UserContent(state)
- 8.}
// CORRECT - lifecycle-aware collection @Composable fun UserScreen(viewModel: UserViewModel, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current) { val state by viewModel.state.collectAsStateWithLifecycle( lifecycle = lifecycleOwner.lifecycle, minActiveState = Lifecycle.State.STARTED ) UserContent(state) }
// For non-Compose Android - lifecycle-aware collection lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collect { state -> updateUI(state) // Only collects when lifecycle is STARTED+ } } }
// If you need the value immediately regardless of lifecycle lifecycleScope.launch { // Get current value immediately updateUI(viewModel.state.value)
// Then collect changes viewModel.state.collect { state -> updateUI(state) } } ```
Prevention
- Use
StateFlowfor state that always has a current value (UI state, config) - Use
SharedFlowfor one-time events (navigation, toasts, dialogs) - Remember that
StateFlowdeduplicates byequals()— wrap in a unique container if needed - Use
collectAsStateWithLifecyclein Compose for lifecycle-aware collection - Always set an initial value for
MutableStateFlowin the property declaration - Test flow emissions with
Turbinelibrary for reliable flow testing