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()
SourceBuilt into Compose's runtimelifecycle-runtime-compose artifact
Lifecycle-aware?No — keeps collecting regardless of visibilityYes — stops below STARTED, like repeatOnLifecycle
Recommended forNon-Android Kotlin, or quick prototypesReal Android app screens — this is Google's current recommendation
Recommendation: Reach for 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.

Next up: MVVM Architecture in Compose ties this together with a proper RecipeRepository layer, showing the full Composable → ViewModel → Repository chain end to end.