StateFlow & collectAsState
RecipeBox's RecipeListViewModel, back in the ViewModel lesson, exposed its list as a
plain mutableStateOf<List<Recipe>> property. That works, but it ties the
ViewModel's public API directly to Compose's own state types. Once a screen's data starts coming
from somewhere genuinely asynchronous and stream-like — a Room DAO query, a repository combining
several sources — StateFlow is the more natural, more testable shape to expose, and
it's what most production Compose codebases actually use.
Expose the Read-Only StateFlow, Not the Mutable One
The same encapsulation principle from LiveData carries over unchanged: keep a private
MutableStateFlow that only the ViewModel can write to, and expose a public,
read-only StateFlow that composables can observe but never assign into.
class RecipeListViewModel(
private val repository: RecipeRepository
) : ViewModel() {
private val _recipes = MutableStateFlow<List<Recipe>>(emptyList())
val recipes: StateFlow<List<Recipe>> = _recipes.asStateFlow()
init {
loadRecipes()
}
fun loadRecipes() {
viewModelScope.launch {
_recipes.value = repository.getAllRecipes()
}
}
}
Calling .asStateFlow() on the mutable instance, rather than just assigning
_recipes straight to recipes, produces a view of the flow whose
declared type is the read-only StateFlow interface — it's the same defensive
pattern as LiveData's public-immutable / private-mutable split, expressed with
coroutines' Flow types instead.
Two Ways to Collect: collectAsState() vs collectAsStateWithLifecycle()
A composable can't call .collect { } directly — that's a suspend function, and a
composable body isn't a coroutine. Compose provides collectAsState(), an extension
function that collects a Flow/StateFlow and exposes its latest value as
Compose State, triggering recomposition on every new emission:
val recipes by viewModel.recipes.collectAsState()
This works, but like the plain lifecycleScope.launch { flow.collect { } } from the Flow
lesson in the Kotlin track, it has no idea whether the screen is actually visible — it keeps
collecting even while the app is backgrounded. collectAsStateWithLifecycle(), from the
androidx.lifecycle:lifecycle-runtime-compose artifact, is the lifecycle-aware
equivalent: it automatically stops collecting when the hosting lifecycle drops below
STARTED and resumes when it comes back, the same safety repeatOnLifecycle
gave you manually before:
val recipes by viewModel.recipes.collectAsStateWithLifecycle()
collectAsState() | collectAsStateWithLifecycle() | |
|---|---|---|
| Source | Built into Compose's runtime | lifecycle-runtime-compose artifact |
| Lifecycle-aware? | No — keeps collecting regardless of visibility | Yes — stops below STARTED, like repeatOnLifecycle |
| Recommended for | Non-Android Kotlin, or quick prototypes | Real Android app screens — this is Google's current recommendation |
collectAsStateWithLifecycle() by default in an actual Android app. collectAsState() is worth knowing because you'll see it in older code and simpler examples, but it wastes work collecting a screen that isn't even visible.Worked Example: The Recipe List, Observing a StateFlow
Putting it together, RecipeListScreen now collects the ViewModel's
StateFlow<List<Recipe>> instead of reading a raw
mutableStateOf property directly:
@Composable
fun RecipeListScreen(
viewModel: RecipeListViewModel = viewModel(),
onRecipeClick: (Int) -> Unit
) {
val recipes by viewModel.recipes.collectAsStateWithLifecycle()
if (recipes.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No recipes yet — tap + to add one")
}
} else {
LazyColumn {
items(recipes, key = { it.id }) { recipe ->
RecipeRow(recipe = recipe, onClick = { onRecipeClick(recipe.id) })
}
}
}
}
The by delegate on collectAsStateWithLifecycle() means recipes
behaves as a plain List<Recipe> value inside the composable — no
.value unwrapping needed — while under the hood it's a coroutine collecting the
ViewModel's flow, scoped to exactly as long as this composable stays visible on screen.
RecipeRepository layer, showing the full Composable → ViewModel → Repository chain end to end.