Course outline

Android development / Intermediate: state, data, and inclusive features / Lesson 7

ViewModel, structured coroutines, and Flow

Create a screen state pipeline whose ownership, cancellation, and error behavior remain understandable during asynchronous updates.

About 4 min readContent reviewed
On this page

Lesson goals

  • Expose immutable screen state from a ViewModel.
  • Distinguish suspension, dispatching, collection, and durable work.

Give business state an owner

A ViewModel owns screen-level state and operations for a specific lifecycle owner. It survives configuration changes for that owner and is cleared when the owner is permanently removed. It should not retain an activity, composable, or view reference. A repository supplies data operations; the ViewModel transforms them into information a screen can render. Expose read-only StateFlow while keeping mutation private. This makes the possible states visible and prevents a UI element from changing shared state without passing through an intentional operation.

Scope work and preserve cancellation

A coroutine runs within a scope whose cancellation controls its children. viewModelScope is useful for screen operations that should outlive a particular composition but stop when the ViewModel is cleared. A suspend function can suspend without blocking its caller, but the suspend keyword does not move blocking code to another thread. Make repository operations main-safe by using suitable dispatchers around blocking work. Catch expected failures narrowly, and rethrow CancellationException when a broad boundary must catch exceptions; cancellation is control flow rather than a user-visible server error.

kotlin A narrow asynchronous screen owner
data class ShelfState(val titles: List<String> = emptyList(), val failed: Boolean = false)
interface ShelfRepository { suspend fun loadTitles(): List<String> }

class ShelfViewModel(private val repository: ShelfRepository) : ViewModel() {
    private val _state = MutableStateFlow(ShelfState())
    val state: StateFlow<ShelfState> = _state.asStateFlow()

    fun refresh() {
        viewModelScope.launch {
            try { _state.value = ShelfState(repository.loadTitles()) }
            catch (e: CancellationException) { throw e }
            catch (e: IOException) { _state.update { it.copy(failed = true) } }
        }
    }
}

Import lifecycle ViewModel/viewModelScope, kotlinx.coroutines and flow APIs, and java.io.IOException. Supply the repository through a ViewModel factory or DI. This minimal example preserves previous content on I/O failure; production state should also model loading and overlapping-refresh policy.

Separate streams from current state

A cold Flow generally starts its producer for each collection. StateFlow is hot and holds a current value, so a new collector receives the latest state. stateIn can share an upstream flow in a chosen scope with an explicit start policy. Understand that stopping collection can stop upstream work under a while-subscribed policy. Do not assume every emission is an event that must be delivered exactly once: state can be conflated. A command such as confirming a purchase needs a durable business contract beyond a transient UI stream.

Observe with the screen lifecycle

Collect StateFlow using the Android lifecycle-aware Compose adapter rather than launching an unbounded collector in the body. The collected value becomes Compose state and drives a stateless screen. Place one-time initialization in the state owner or a deliberately keyed effect, rather than invoking refresh during every render. When a user changes a search query rapidly, operators such as debounce and flatMapLatest can cancel obsolete searches, but cancellation does not replace server-side consistency or prevent a write already accepted by a backend.

kotlin Observe state and forward a retry event
@Composable
fun ShelfRoute(viewModel: ShelfViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    ShelfScreen(
        titles = state.titles,
        showError = state.failed,
        onRetry = viewModel::refresh
    )
}

Add a compatible lifecycle-runtime-compose dependency and import collectAsStateWithLifecycle and the Compose getValue delegate. ShelfScreen is the app’s stateless renderer. This route does not start a refresh automatically; its owner decides initial loading policy.

Decide what overlapping operations mean

Two refreshes can finish in the opposite order from their start times. Choose whether to cancel the previous request, serialize requests, or discard stale results by generation. Atomic StateFlow update prevents a local read-modify-write race, but cannot alone resolve request ordering. Inject dispatchers and dependencies so tests can control time and outcomes. Finally, classify work by durability: saving a confirmed operation that must continue after the process exits belongs in durable storage and an appropriate scheduler, not a longer-lived global coroutine invented to escape lifecycle cancellation.

Common errors and fixes

A suspend function still freezes scrolling

Why it happens
Blocking work runs on the main dispatcher.
How to fix it
Move that blocking implementation to an injected suitable dispatcher.
Prevent it next time
Review main-safety at repository boundaries.

Canceled searches appear as failures

Why it happens
A broad catch swallowed CancellationException.
How to fix it
Rethrow cancellation and map only relevant failures.
Prevent it next time
Test cancellation independently from network errors.

Older results overwrite a newer query

Why it happens
Concurrent requests have no ordering policy.
How to fix it
Cancel obsolete reads or reject stale generations.
Prevent it next time
Test deliberately reversed completion order.

Check your understanding

1. A repository returns a cold Flow performing a database query, and two UI collectors subscribe. Explain the likely producer behavior and a sharing decision.

Show worked solution

Each collection can start its own producer. If the screen needs one shared current state, use stateIn in the ViewModel with an intentional start policy and initial state. Keep the scope aligned with screen ownership, and test what happens when subscribers disappear and return.

2. A user confirms an upload and immediately closes the app. Is viewModelScope sufficient? Design the ownership handoff.

Show worked solution

No. Persist the operation and its stable identity before claiming it is queued, then schedule suitable durable work. The UI observes status from storage. A ViewModel coroutine may initiate that handoff, but its in-memory job is not the durable record and cannot guarantee progress after process death.

Key takeaways

  • Suspension and thread switching are different decisions.
  • State streams need explicit scope and restart semantics.
  • Cancellation, ordering, and durability require separate policies.

Official references