Not every state holder is a ViewModel

Compose has two kinds of state holder: plain classes that live in the composition, and ViewModels that outlive it. Knowing which to reach for is what keeps UiState classes from growing to twenty fields.

6 min read
androidcomposekotlinarchitecture

Day 17 — Not every state holder is a ViewModel

Day 17 of 100, and the last of the UI-architecture pillar. Yesterday's layering had a box labelled "state holders" that quietly contained two different things. Today: which is which.

The symptom

A screen composable that has become a pile of related variables:

@Composable
fun ChatScreen(vm: ChatViewModel = viewModel()) {
    val listState = rememberLazyListState()
    val scope = rememberCoroutineScope()
    var isAtBottom by remember { mutableStateOf(true) }
    var showJumpButton by remember { mutableStateOf(false) }
    val snackbarHost = remember { SnackbarHostState() }

    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .collect { isAtBottom = it == 0; showJumpButton = it > 5 }
    }
    …
}

Nothing here is wrong by the last six days' rules. It's all UI element state, all correctly local. And the screen composable is now forty lines of coordination before any UI appears, and none of it can be tested or reused.

Why the obvious fix fails

The obvious fix is to push it into the ViewModel:

class ChatViewModel : ViewModel() {
    var isAtBottom by mutableStateOf(true)
    var showJumpButton by mutableStateOf(false)
    …
}

This is Day 13's over-hoisting, and it has an extra cost here. Scroll state has the lifetime of the composition, not the screen — if the list leaves and comes back, its scroll position should reset, and a ViewModel-held one won't. You've also made the ViewModel depend on Compose (mutableStateOf), so its unit tests now need the Compose runtime.

The pile is a real problem. The ViewModel is the wrong destination.

Two kinds of state holder: plain classes scoped to the composition, ViewModels scoped to the screen

The actual mechanism

There are two kinds of state holder, distinguished by lifetime and by what they know:

Plain state-holder classes live in the composition, hold UI element state, and know about UI concerns. They're created with remember, they can depend on Compose types, and they die when the composable leaves.

ViewModels outlive the composition, hold screen state, and know about the data layer. They know nothing about Compose.

The first kind is the one people don't write, even though they use it constantly: rememberLazyListState(), rememberScrollState(), rememberPagerState(), rememberDrawerState() are all exactly this. The convention is right there in the API, and it's copyable:

class ChatListState(
    val listState: LazyListState,
    private val scope: CoroutineScope,
) {
    val isAtBottom: Boolean get() = listState.firstVisibleItemIndex == 0
    val showJumpButton: Boolean get() = listState.firstVisibleItemIndex > 5

    fun jumpToBottom() { scope.launch { listState.animateScrollToItem(0) } }
}

@Composable
fun rememberChatListState(
    listState: LazyListState = rememberLazyListState(),
    scope: CoroutineScope = rememberCoroutineScope(),
) = remember(listState, scope) { ChatListState(listState, scope) }
@Composable
fun ChatScreen(vm: ChatViewModel = viewModel()) {
    val chat = rememberChatListState()
    val messages by vm.messages.collectAsStateWithLifecycle()

    ChatContent(messages, chat, onSend = vm::send)
}

Four lines. The coordination logic moved into a class with a name, and both derived flags became get() properties — computed, not stored, so Day 13's derived-state pitfall can't happen here at all.

The remember key is the whole contract

Note remember(listState, scope) rather than bare remember. The holder captures both, so if either is replaced the holder must be rebuilt — the same keying discipline as LaunchedEffect, applied to an object rather than an effect.

Getting this wrong produces a holder pointing at a stale LazyListState, which manifests as scroll methods that silently do nothing. It's the least obvious bug in the pattern, and the key is the entire fix.

The naming convention matters more than it looks, too: rememberXState() returning X tells every reader the lifetime without reading the body. Deviating from it — a createXState() or a bare constructor call — is how these end up accidentally recreated on every recomposition.

Choosing between them

Plain class ViewModel
Lifetime composition screen / navigation entry
Survives rotation no yes
Holds UI element state screen state
Knows about Compose yes no
Knows about the data layer no yes
Created with remember viewModel()
Tested with Compose test plain JVM test

The decision procedure is two questions in order. Does it need to survive a configuration change? Yes → ViewModel. Does it need the data layer? Yes → ViewModel. Otherwise, and this is most coordination logic, a plain class.

The two compose cleanly, which is the point of separating them: a screen holds one ViewModel for its data and any number of small plain holders for its UI mechanics, and neither knows the other exists.

When you need neither

A holder that exists to hold one boolean is worse than the boolean:

class ExpandedState { var expanded by mutableStateOf(false) }   // just use remember

The threshold is roughly three related values plus behaviour that acts on them. Below that, a remember in the composable is clearer than a class, and the indirection costs more than it saves.

The same restraint applies to ViewModels. A screen with no data and no logic — a static "about" page — doesn't need one, and adding it doesn't make the app more architectural.

The reusable-component version

The pattern isn't only for screens. It's how you give a complex reusable component a public API without exposing its internals:

class CarouselState internal constructor(
    internal val pagerState: PagerState,
) {
    val currentPage: Int get() = pagerState.currentPage
    suspend fun scrollTo(page: Int) = pagerState.animateScrollToPage(page)
}

@Composable
fun rememberCarouselState(pageCount: () -> Int) =
    rememberPagerState(pageCount = pageCount).let { remember(it) { CarouselState(it) } }

Callers get currentPage and scrollTo; they don't get the PagerState to fiddle with. That's ordinary encapsulation, and the rememberXState() convention is what makes it feel native rather than like an extra layer. Every non-trivial component in a design system ends up wanting this, usually about two releases after it shipped without it.

How to prove it

Two checks, both fast.

The length test. If a screen composable is more than about fifteen lines before the first UI element, it's coordinating rather than composing, and there's a state holder waiting to be extracted.

The rotation test. Rotate and note what resets. Anything that resets and shouldn't is in a plain holder and belongs in a ViewModel. Anything that persists and should have reset — a scroll position that survives across two different lists, say — is in the ViewModel and belongs in a plain holder. That second direction is the one nobody checks, and it's a real class of bug.

What this generalizes to

The underlying idea is that objects should be scoped to the lifetime of what they describe — the same principle as Day 12, applied to behaviour rather than data. A scroll coordinator scoped to a screen is as wrong as a screen's draft scoped to a widget; both mismatches produce bugs that look like framework weirdness.

That closes the UI-architecture pillar. Eleven days from "recomposition re-runs scopes" to a full picture of where every value and every behaviour in a Compose app belongs. Tomorrow, Day 18, starts the layout pillar: Column, Row, Box, and the measurement model that makes Compose's single-pass layout possible.


Day 17 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: State holders and UI state.