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 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.

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.