On this page
Bugs like "the spinner never disappears", "the error shows over the data" or "the list flickers empty on rotation" are state-modelling bugs. The fix isn't more if-statements in the UI. It's a better-shaped state object, exposed through a single, predictable stream.
Principles
- One
StateFlow<UiState>per screen, which is the single source of truth for rendering. - Immutable state:
data classwithvals, updated withcopy. - Make impossible states impossible: choose between sealed and data class deliberately.
- Derive, don't duplicate: compute values from sources rather than storing copies.
Choosing the shape: sealed vs data class
| Screen behaviour | Shape | Example |
|---|---|---|
| Mutually exclusive modes | Sealed interface | Loading / Content / Error for a detail page |
| Fields that coexist | Data class | A list that stays visible while refreshing, with an optional message |
| Both | Data class containing a sealed field | FeedUiState(query, results: ResultsState) |
Building state from several sources
data class TasksUiState(
val tasks: List<TaskUi> = emptyList(),
val filter: TaskFilter = TaskFilter.ALL,
val isRefreshing: Boolean = false,
val userMessage: String? = null,
val isEmpty: Boolean = false,
)
@HiltViewModel
class TasksViewModel @Inject constructor(
private val repository: TaskRepository,
private val savedState: SavedStateHandle,
) : ViewModel() {
private val filter = savedState.getStateFlow("filter", TaskFilter.ALL)
private val refreshing = MutableStateFlow(false)
private val message = MutableStateFlow<String?>(null)
val uiState: StateFlow<TasksUiState> = combine(
repository.observeTasks(), filter, refreshing, message,
) { tasks, f, isRefreshing, msg ->
val visible = tasks.filter(f::matches).map(Task::toUi)
TasksUiState(visible, f, isRefreshing, msg, isEmpty = visible.isEmpty() && !isRefreshing)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TasksUiState(isRefreshing = true),
)
fun setFilter(f: TaskFilter) { savedState["filter"] = f }
fun refresh() = viewModelScope.launch {
refreshing.value = true
try { repository.refresh() }
catch (e: IOException) { message.value = "Couldn't sync. Showing saved tasks." }
finally { refreshing.value = false }
}
fun messageShown() { message.value = null }
}Imperative updates with update { }
For state the ViewModel owns directly, such as form input, use a MutableStateFlow and update, which is atomic and safe under concurrency:
private val _form = MutableStateFlow(SignupForm())
val form: StateFlow<SignupForm> = _form.asStateFlow()
fun onEmailChange(email: String) = _form.update {
it.copy(email = email, emailError = if (email.isBlank() || "@" in email) null else "Invalid email")
}Messages and one-off events as state
Store user messages in state and clear them after display, as in userMessage and messageShown() above. Navigation can be modelled the same way (navigateTo: Destination?), or triggered directly by the UI after observing a result state. See Flow vs StateFlow vs SharedFlow for the trade-offs.
Consuming in Compose
@Composable
fun TasksRoute(viewModel: TasksViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() }
state.userMessage?.let { msg ->
LaunchedEffect(msg) { snackbar.showSnackbar(msg); viewModel.messageShown() }
}
TasksScreen(state, snackbar, onFilter = viewModel::setFilter, onRefresh = viewModel::refresh)
}Testing state
@Test
fun `failed refresh keeps tasks and shows message`() = runTest {
val repo = FakeTaskRepository(tasks = listOf(task("Write tests")), refreshFails = true)
val vm = TasksViewModel(repo, SavedStateHandle())
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.uiState.collect {} }
vm.refresh(); advanceUntilIdle()
with(vm.uiState.value) {
assertEquals(1, tasks.size)
assertEquals("Couldn't sync. Showing saved tasks.", userMessage)
assertFalse(isRefreshing)
}
}Collecting in backgroundScope activates WhileSubscribed upstreams during the test.
Patterns by screen type
Detail screen
Sealed: Loading, Content(item) or Error(message, canRetry).
List with refresh
Data class: items, isRefreshing and message. Items stay visible during refresh.
Multi-step form
Data class per step plus a sealed submission status. Input is kept in SavedStateHandle.
Common mistakes
- Several public StateFlows for one screen that the UI must combine.
- Emitting new state objects with identical content in tight loops, which wastes recomposition.
SharingStarted.Eagerlyfor screen state, keeping work alive when invisible.- UI-toolkit types (Compose
Color,Painter) in UiState. Use semantic values and resource IDs.
Frequently asked questions
Should formatting happen in the ViewModel?
Simple display formatting (dates, prices) in the ViewModel or a UI mapper keeps composables simple and testable. Locale-sensitive formatting can also live in composables if it depends on the configuration.
Is a big UiState bad for performance?
Usually not. Compose skips parts whose inputs are unchanged. Split state only if unrelated parts update at very high frequency.
Compose State or StateFlow in the ViewModel?
Both are supported. StateFlow is toolkit-agnostic and integrates with Flow operators, which is why it's the most common choice.
Conclusion
Expose one immutable StateFlow per screen, shape it to rule out impossible combinations, build it with combine and stateIn(WhileSubscribed), and treat messages as consumable state. Screens become simple renderers, and ViewModels become easy to test.
Next step: see the principle behind all this in Unidirectional Data Flow in Android.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Architecture makes the most sense when you apply it to a real project and see why each boundary exists. Learn how maintainable Android applications are designed by building one.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.