๐Ÿ›๏ธ Android Architecture and Clean Code

Managing UI State with ViewModel and StateFlow

A screen's UI state is everything the UI needs to render: data, loading flags, errors, input. How you model and expose it decides whether screens are predictable or full of edge-case bugs. Here are the patterns that work, with complete Kotlin code.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Intermediate
On this page
  1. Principles
  2. Choosing the shape: sealed vs data class
  3. Building state from several sources
  4. Imperative updates with update { }
  5. Messages and one-off events as state
  6. Consuming in Compose
  7. Testing state
  8. Patterns by screen type
  9. Common mistakes
  10. Frequently asked questions
  11. Conclusion

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 class with vals, updated with copy.
  • 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 behaviourShapeExample
Mutually exclusive modesSealed interfaceLoading / Content / Error for a detail page
Fields that coexistData classA list that stays visible while refreshing, with an optional message
BothData class containing a sealed fieldFeedUiState(query, results: ResultsState)

Building state from several sources

TasksViewModel.ktKotlin
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 }
}
Combining sources into one UI state Four sources, the repository's task Flow, the saved filter, a refreshing flag and a user message, are combined into one TasksUiState and exposed with stateIn as a single StateFlow collected by the screen. observeTasks() filter (SavedState) refreshing userMessage combine+ stateIn StateFlow<TasksUiState>โ†’ collected by UI
Many inputs, one output. The UI never has to reconcile separate streams.

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:

Form stateKotlin
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

TasksRoute.ktKotlin
@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

TasksViewModelTest.ktKotlin
@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.Eagerly for 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.

See all our apps โ†’

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.

Share: LinkedIn X / Twitter WhatsApp