utable properties.
data class ProfileUiState(
val isLoading: Boolean = false,
val username: String = "",
val avatarUrl: String? = null,
val error: String? = null
)
sealed interface ProfileEvent {
data object LoadProfile : ProfileEvent
data class UpdateUsername(val newName: String) : ProfileEvent
data object Retry : ProfileEvent
}
Step 2: Implement StateFlow with Explicit Reduction
Use a private MutableStateFlow for internal state management. Expose only the read-only StateFlow. State reduction must be pure.
class ProfileViewModel(
private val repository: ProfileRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
init {
// Restore minimal state if needed
savedStateHandle.get<String>("last_username")?.let { saved ->
_uiState.update { it.copy(username = saved) }
}
processEvent(ProfileEvent.LoadProfile)
}
fun processEvent(event: ProfileEvent) {
viewModelScope.launch {
when (event) {
is ProfileEvent.LoadProfile -> loadProfile()
is ProfileEvent.UpdateUsername -> updateUsername(event.newName)
is ProfileEvent.Retry -> loadProfile()
}
}
}
private suspend fun loadProfile() {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val profile = repository.fetchProfile()
_uiState.update {
it.copy(isLoading = false, username = profile.name, avatarUrl = profile.avatar)
}
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.localizedMessage) }
}
}
private suspend fun updateUsername(newName: String) {
_uiState.update { it.copy(isLoading = true) }
repository.saveUsername(newName)
_uiState.update { it.copy(isLoading = false, username = newName) }
}
override fun onCleared() {
super.onCleared()
// Persist minimal state to SavedStateHandle
_uiState.value.username.let {
// Note: SavedStateHandle should only store primitives/Parcels
}
}
}
Step 3: Scope ViewModel Correctly
ViewModel scope dictates state lifecycle. Use navigation component scoping to prevent cross-screen leakage.
// Activity scope: persists across all fragments in the activity
val activityViewModel: ProfileViewModel by activityViewModels()
// Fragment scope: tied to fragment lifecycle
val fragmentViewModel: ProfileViewModel by viewModels()
// Navigation graph scope: persists across backstack within a feature
val navGraphViewModel: ProfileViewModel by navGraphViewModels(R.id.profile_graph)
Step 4: Integrate with UI (XML or Compose)
For XML: Collect flows in LifecycleOwner scope to prevent memory leaks.
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
if (state.isLoading) progressBar.visibility = View.VISIBLE
else progressBar.visibility = View.GONE
usernameTextView.text = state.username
errorTextView.text = state.error
}
}
}
For Compose: Hoist state directly or consume via collectAsStateWithLifecycle().
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when {
state.isLoading -> CircularProgressIndicator()
state.error != null -> Text(state.error, color = Color.Red)
else -> Text("Welcome, ${state.username}")
}
}
Architecture Decisions and Rationale
- StateFlow over LiveData: StateFlow guarantees at least one emission, integrates natively with coroutines, and enforces cold/warm stream semantics. LiveData’s main-thread dispatcher and lack of backpressure handling make it unsuitable for complex state pipelines.
- Immutable State: Prevents accidental mutations from multiple UI components. State reduction via
.update {} ensures atomic transitions and simplifies debugging.
- Event/State Separation: Events trigger transitions; state represents the result. This eliminates race conditions and enables deterministic testing.
- SavedStateHandle Constraints: Only store primitives, strings, or
@Parcelize objects. Large objects belong in Room/DataStore. ViewModel state survives configuration changes; SavedStateHandle survives process death.
Pitfall Guide
1. Exposing MutableStateFlow Directly to UI
Mistake: val uiState = MutableStateFlow(State()) exposed publicly.
Impact: UI components mutate state directly, bypassing business logic. Race conditions emerge when multiple observers update simultaneously.
Fix: Always expose StateFlow<T> via .asStateFlow(). Mutations must flow through explicit event handlers.
2. Overusing savedStateHandle for Complex Objects
Mistake: Storing large data classes or network responses in savedStateHandle.
Impact: Bundle size limits (typically 1MB) trigger TransactionTooLargeException. Process death recovery fails silently or crashes.
Fix: Use savedStateHandle only for navigation parameters and minimal UI flags. Persist business data in Room, DataStore, or cache layers.
3. ViewModel Scope Mismatch
Mistake: Using by viewModels() for shared feature state, or by activityViewModels() for isolated screens.
Impact: State persists across unrelated screens (memory leak) or resets prematurely during back navigation (UX degradation).
Fix: Map ViewModel scope to navigation boundaries. Use navGraphViewModels() for feature-level state, viewModels() for screen-local state.
4. Mixing UI Events and State in Single Flow
Mistake: Emitting both state updates and one-off events (snackbars, navigation) through the same StateFlow.
Impact: Events re-fire on configuration changes because StateFlow caches the last value. Users see duplicate toasts or unintended navigation.
Fix: Separate concerns. Use StateFlow for persistent UI state. Use SharedFlow or Channel for one-time events. Consume events with LaunchedEffect or repeatOnLifecycle.
5. Blocking Main Thread in State Collection
Mistake: Collecting flows without lifecycleScope or repeatOnLifecycle.
Impact: State updates trigger UI work while the screen is stopped, causing wasted CPU cycles and potential crashes when views are detached.
Fix: Always scope collection to Lifecycle.State.STARTED or RESUMED. Use collectAsStateWithLifecycle() in Compose.
6. Ignoring Backpressure in Heavy State Pipelines
Mistake: Emitting state updates faster than the UI can render (e.g., rapid search input, scroll events).
Impact: UI jank, dropped frames, and ANR traces. StateFlow buffers emissions, but uncontrolled upstream pressure degrades performance.
Fix: Apply debounce, distinctUntilChanged, or conflate upstream. Filter state at the repository layer before reaching the ViewModel.
7. Testing ViewModels Without Coroutine Test Dispatchers
Mistake: Running ViewModel tests on UnconfinedTestDispatcher without controlling virtual time.
Impact: Tests pass locally but fail in CI due to race conditions. State transitions complete out of order.
Fix: Use StandardTestDispatcher with runTest. Advance virtual time explicitly. Verify state transitions deterministically.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple form input with validation | ViewModel + StateFlow (single state class) | Low overhead, predictable transitions, easy testing | Low |
| Multi-screen feature with shared cart state | NavGraph-scoped ViewModel + StateFlow | Prevents state loss across backstack, avoids duplication | Medium |
| Real-time dashboard with rapid updates | ViewModel + ConflatedStateFlow + debounce | Prevents UI thread saturation, maintains responsiveness | Medium |
| Legacy XML codebase migration | ViewModel + StateFlow with LiveData adapter | Gradual migration path, preserves existing XML bindings | High initially, Low long-term |
| New Compose-only feature | State hoisting to Compose + ViewModel for business logic | Eliminates redundant abstraction, aligns with declarative paradigm | Low |
Configuration Template
// ui/FeatureState.kt
data class FeatureUiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val error: String? = null,
val selectedId: String? = null
)
sealed interface FeatureEvent {
data object Load : FeatureEvent
data class Select(val id: String) : FeatureEvent
data class Retry(val error: Throwable) : FeatureEvent
}
// ViewModel/FeatureViewModel.kt
class FeatureViewModel(
private val repository: FeatureRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow(FeatureUiState())
val uiState: StateFlow<FeatureUiState> = _uiState.asStateFlow()
private val _events = MutableSharedFlow<FeatureUiEvent>(extraBufferCapacity = 1)
val events: SharedFlow<FeatureUiEvent> = _events.asSharedFlow()
init {
savedStateHandle.get<String>("last_selected")?.let {
_uiState.update { it.copy(selectedId = it) }
}
processEvent(FeatureEvent.Load)
}
fun processEvent(event: FeatureEvent) {
viewModelScope.launch {
when (event) {
is FeatureEvent.Load -> load()
is FeatureEvent.Select -> select(event.id)
is FeatureEvent.Retry -> load()
}
}
}
private suspend fun load() {
_uiState.update { it.copy(isLoading = true, error = null) }
runCatching { repository.fetchItems() }
.onSuccess { items ->
_uiState.update { it.copy(isLoading = false, items = items) }
}
.onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
}
private suspend fun select(id: String) {
_uiState.update { it.copy(selectedId = id) }
savedStateHandle["last_selected"] = id
_events.emit(FeatureUiEvent.ItemSelected(id))
}
override fun onCleared() {
super.onCleared()
// Cleanup if necessary
}
}
Quick Start Guide
- Define immutable state: Create a
data class representing all possible UI conditions (loading, success, error, selection).
- Initialize ViewModel: Inject dependencies, set up private
MutableStateFlow, expose read-only StateFlow, and trigger initial load in init.
- Wire collection: In XML, use
lifecycleScope.launch { repeatOnLifecycle(STARTED) { viewModel.uiState.collect { ... } } }. In Compose, use collectAsStateWithLifecycle().
- Handle events: Call
viewModel.processEvent(FeatureEvent.Load) from UI callbacks. Never mutate state directly from the UI layer.
- Test deterministically: Use
runTest with StandardTestDispatcher. Verify state transitions after each event emission. Assert uiState.value matches expected conditions.