ViewModel in Compose
Every composable in the RecipeBox app so far has kept its state with remember — fine
for a text field's current draft, a checkbox's checked flag, or a dialog's visibility. But the
recipe list itself, the logged-in user, the in-progress "Add Recipe" form — that's real
application state, and it needs to survive things a plain composable's memory
never does: the screen rotating, the system trimming memory in the background, or the user
navigating to a detail screen and back. This lesson introduces ViewModel in Compose —
the same architecture component you may already know from View-based Android, now obtained through
a composable function instead of by viewModels().
remember Belongs to the Composition — ViewModel Belongs to the Screen
remember ties a value to the composition — the tree of composables
currently on screen. When a composable leaves the composition (the user navigates away, or a
configuration change tears down and rebuilds the whole Activity), anything held in
remember is gone, full stop. That's exactly right for transient UI state like "is this
dropdown expanded," but it's the wrong tool for RecipeBox's recipe list — you don't want the list
to flash away and reload every time the phone rotates.
ViewModel is scoped differently: to a ViewModelStoreOwner — typically the
hosting Activity, or a specific destination in the navigation graph — which survives configuration
changes even though the composable functions observing it are torn down and re-run constantly.
The system keeps the exact same ViewModel instance alive across a rotation and simply
reconnects the recomposed UI to it.
remember | ViewModel | |
|---|---|---|
| Scope | Tied to the composition (this composable's slot in the UI tree) | Tied to a ViewModelStoreOwner (Activity, NavBackStackEntry, ...) |
| Survives rotation? | No — lost when the Activity is recreated | Yes — the same instance is handed back |
| Survives navigating away and back? | No, if the composable leaves the composition | Depends on scope — can outlive a single screen |
| Good for | Transient UI-only state (expanded/collapsed, scroll position, draft text) | Screen-level state, business logic, data loading |
Obtaining a ViewModel: the viewModel() Composable Function
Compose provides a composable function, viewModel(), from the
androidx.lifecycle:lifecycle-viewmodel-compose artifact. Called inside a composable,
it looks up (or, the first time, creates) a ViewModel instance scoped to the current
NavBackStackEntry if you're using Navigation Compose, or to the nearest
ViewModelStoreOwner otherwise — usually the hosting Activity:
@Composable
fun RecipeListScreen(
viewModel: RecipeListViewModel = viewModel()
) {
// viewModel is the SAME instance across recompositions and rotations
}
Passing viewModel() as a default parameter value, as shown above, is
the idiomatic pattern — it lets Compose supply the real ViewModel in production while still
letting a test or a @Preview pass in a fake one explicitly, without changing the
function's body at all.
Worked Example: RecipeListViewModel
RecipeBox's recipe list screen needs a place to hold the currently loaded recipes and a function
to (re)load them. That's exactly what a ViewModel is for:
class RecipeListViewModel(
private val repository: RecipeRepository
) : ViewModel() {
var recipes by mutableStateOf<List<Recipe>>(emptyList())
private set
var isLoading by mutableStateOf(false)
private set
init {
loadRecipes()
}
fun loadRecipes() {
viewModelScope.launch {
isLoading = true
recipes = repository.getAllRecipes()
isLoading = false
}
}
}
mutableStateOf here is Compose's own observable state holder — the same kind
remember { mutableStateOf(...) } wraps inside a composable — but nothing here stops
the ViewModel from holding it directly, without remember, because the
ViewModel itself already has the right lifetime. The private set keeps writes confined
to the ViewModel, while any composable can still read recipes and isLoading.
viewModelScope is a coroutine scope tied to the ViewModel's own lifetime — it's
automatically cancelled when the ViewModel is cleared, so there's no manual cleanup to write.
Consuming the ViewModel from a Composable
With the ViewModel defined, the recipe list screen simply reads its exposed properties — Compose
takes care of recomposing this composable whenever recipes or isLoading
changes, exactly like it would for any other mutableStateOf:
@Composable
fun RecipeListScreen(
viewModel: RecipeListViewModel = viewModel(),
onRecipeClick: (Int) -> Unit
) {
if (viewModel.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
LazyColumn {
items(viewModel.recipes) { recipe ->
RecipeRow(recipe = recipe, onClick = { onRecipeClick(recipe.id) })
}
}
}
}
Notice that RecipeListScreen never constructs RecipeListViewModel itself,
never calls loadRecipes() from a click handler here (the init block already
triggered the first load), and never touches a coroutine directly. All of that — data loading,
loading-state tracking, business logic — lives in the ViewModel, and the composable's only job is to
describe what the UI looks like for whatever state the ViewModel currently exposes. That separation
of "what the screen looks like" from "where the data comes from and how it changes" is the seed of
the next lesson.