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.
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 case this post is about
One chat screen that became a pile of 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) }
}
Four values and two of them are derived from the first. isAtBottom and showJumpButton are both
functions of the scroll position, kept as separate state and updated by effects that have to stay in
step with it.
The fix that comes first moves them into the ViewModel, which is how a ViewModel ends up holding a scroll position — a value that belongs to a list widget, lifted into the thing that survives the screen.
This post follows those four lines, because there is a third place for state that neither the composable nor the ViewModel is, and it is the half of the pattern nobody teaches.
The symptom: a screen composable that became a pile of variables
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 pushing it into the ViewModel isn't the answer
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.

The mechanism: two kinds of state holder, split by lifetime
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 fast checks
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: scope objects to the lifetime of what they hold
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.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".