DevBits
DevBitsAndroid Handbook
Jetpack Compose

How should you observe ViewModel state in Compose?

Observe screen state with lifecycle awareness without coupling the rest of the UI to the ViewModel.

CMP-008Intermediate~30 sec

What the interviewer is testing

This can sound like an API question, but remembering collectAsStateWithLifecycle() is only the surface of it.

The interviewer is really looking for whether you understand ownership, lifecycle, and the boundary between screen logic and rendering.

A strong answer keeps the ViewModel as the screen-level state holder, observes it in a lifecycle-aware way, and avoids making every child composable depend on that state holder directly.

The important distinction is that observing state does not make the UI its owner.

❌ Common mistake

If they ask you to elaborate

Imagine an order screen whose ViewModel owns the current UI state:

class OrderViewModel : ViewModel() {
    val uiState: StateFlow<OrderUiState> = ...

    fun retry() {
        // Retry loading the order.
    }
}

The screen-level composable can connect that state holder to Compose:

@Composable
fun OrderRoute(
    viewModel: OrderViewModel = viewModel(),
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    OrderScreen(
        state = uiState,
        onRetry = viewModel::retry,
    )
}

OrderRoute knows about the ViewModel because that is where screen-level state meets the UI.

The rendering composable doesn't need that dependency:

@Composable
fun OrderScreen(
    state: OrderUiState,
    onRetry: () -> Unit,
) {
    when (state) {
        OrderUiState.Loading -> LoadingContent()
        is OrderUiState.Content -> OrderContent(state.order)
        is OrderUiState.Error -> ErrorContent(
            message = state.message,
            onRetry = onRetry,
        )
    }
}

Now the responsibilities are clear:

  • the ViewModel owns and produces the screen state;
  • the screen-level composable observes that state;
  • the UI below renders the current value;
  • user actions travel back toward the owner that can decide what should change.

collectAsStateWithLifecycle() matters because an Android screen has a lifecycle outside Compose itself. When the UI is no longer in an active lifecycle state, it generally doesn't need to keep collecting updates just because the ViewModel is still alive.

That also connects back to state hoisting. Moving state into a ViewModel changes who owns it. Calling collectAsStateWithLifecycle() does not move ownership again; it simply gives Compose the current value so the UI can react to changes.

There is also a reason the recommendation is specifically lifecycle-aware on Android. collectAsState() is still useful in platform-agnostic Compose code, but Android UI has a lifecycle that should usually be part of the observation decision.

Production thinking

The boundary becomes much more valuable as a screen grows.

Imagine a checkout screen where the address section, payment selector, order summary, error banner, and confirmation button all receive the same ViewModel and reach into it whenever they need something.

At first that can feel convenient. Over time, those components become tied to one screen-level state holder. Reusing one in another screen becomes awkward, previews need more setup, and it becomes harder to tell which parts of the ViewModel each component actually depends on.

I prefer to keep the architecture dependency near the screen boundary and let the UI below work with ordinary values and actions:

ViewModel

lifecycle-aware observation

screen state

UI composables

user actions

A single uiState is a useful default when several values together describe one coherent screen. It gives the UI one snapshot to render and keeps related state changes easy to reason about. But it is not a rule. If parts of a screen are genuinely unrelated and change independently, separate state streams can be clearer than forcing everything into one large object.

I also wouldn't split state just because I'm worried that changing one field will "recompose the whole screen." If uiState changes, the composable that read it can be scheduled for recomposition, but Compose can skip child composables whose inputs have not changed when they are eligible to be skipped.

That is another reason to keep child dependencies focused. If a header only needs an Order, pass the Order rather than the entire OrderUiState. The API becomes clearer, and a change to something unrelated such as isSubmitting does not change the value that header receives.

The goal is not to chase the fewest possible recompositions. Recomposition is normal Compose behaviour. The goal is to model state coherently, keep dependencies narrow, and give the UI meaningful boundaries where unchanged work can stay unchanged.

That doesn't mean there must be exactly one collection call at the top of every screen, or that every project must use a composable named Route. Those are implementation details.

The durable rule is to keep the dependency intentional: screen-level components can know about screen-level state holders; reusable rendering components usually shouldn't need to.

Lifecycle-aware collection also matters beyond correctness. If the UI stops collecting while it is inactive, upstream reactive work can also be designed to stop when nobody is consuming it instead of continuing unnecessarily in the background.

Follow-up questions

What separates a senior answer

Key takeaways

Learn more

On this page