Room Database with Compose
Every RecipeBox list you've built so far in this track has been handed a plain
List<Recipe> that someone else already loaded. This lesson fills in that missing
piece for real: wiring a Room database straight through a ViewModel into a Compose screen, so that
saving, editing, or deleting a recipe anywhere in the app makes the recipe list update itself —
with zero manual "refresh" code anywhere.
@Entity, @Dao, @Database) from Room Database (Kotlin) — this lesson focuses specifically on connecting Room's Flow output to Compose's state system, not on Room itself from scratch.The RecipeDao: Returning Flow Instead of a One-Shot List
A DAO method can return a plain List<Recipe> from a suspend fun —
that gives you the recipes as they are right now, once. But Room supports something
more useful for a screen that should always show the current data: a DAO method can return
Flow<List<Recipe>> directly, and Room takes care of re-running the query
and emitting a fresh list through that Flow every single time any row in the underlying
table changes:
RecipeDao.kt
@Dao
interface RecipeDao {
@Query("SELECT * FROM Recipe ORDER BY title ASC")
fun getAllRecipes(): Flow<List<Recipe>>
@Insert
suspend fun insertRecipe(recipe: Recipe)
@Delete
suspend fun deleteRecipe(recipe: Recipe)
}
Notice getAllRecipes() is not a suspend fun — it doesn't
need to be, because it never returns a single result and stops. It returns a live, ongoing stream:
call it once, collect the Flow it hands back, and that collection keeps receiving new
lists for as long as it stays active, with no further calls to getAllRecipes() ever
needed.
Collecting the Flow in a ViewModel, Exposed as StateFlow
A raw Flow from the DAO could be collected directly inside a composable, but the
idiomatic pattern — matching StateFlow &
collectAsState earlier in this track — is to let the ViewModel convert it into a
StateFlow first. That gives every composable a current value to read immediately, even
before the first emission arrives, and keeps the Compose layer completely decoupled from Room's
Flow API:
RecipeListViewModel.kt
class RecipeListViewModel(
private val recipeDao: RecipeDao
) : ViewModel() {
val recipes: StateFlow<List<Recipe>> = recipeDao.getAllRecipes()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun addRecipe(recipe: Recipe) {
viewModelScope.launch {
recipeDao.insertRecipe(recipe)
}
}
fun removeRecipe(recipe: Recipe) {
viewModelScope.launch {
recipeDao.deleteRecipe(recipe)
}
}
}
stateIn is what turns the DAO's cold Flow into a hot StateFlow
that keeps running inside viewModelScope. SharingStarted.WhileSubscribed(5000)
means Room's underlying query stays active while at least one composable is collecting it, and for
five extra seconds after the last one stops — long enough to survive a quick screen rotation without
re-querying the database from scratch, but not so long that it keeps running forever after the user
has navigated away for good. initialValue = emptyList() gives the UI something sane to
show on the very first composition, before Room's first emission has even arrived.
Rendering It: collectAsState + LazyColumn
From the composable's side, none of the Room or stateIn machinery above is visible at
all — it just reads recipes as a StateFlow, same as any other ViewModel
state in this track:
RecipeListScreen.kt
@Composable
fun RecipeListScreen(
viewModel: RecipeListViewModel = viewModel()
) {
val recipes by viewModel.recipes.collectAsState()
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items = recipes, key = { it.id }) { recipe ->
RecipeRow(recipe = recipe)
}
}
}
Follow the whole chain end to end: a user taps "Save" on the Add Recipe screen, which calls
viewModel.addRecipe(newRecipe), which runs recipeDao.insertRecipe(newRecipe).
Room notices the Recipe table changed, re-runs the SELECT query behind
getAllRecipes(), and emits the new, complete list through the Flow. That
flows straight through stateIn into viewModel.recipes, and
collectAsState() triggers a recomposition of RecipeListScreen with the new
list — even if that screen wasn't the one that made the insert. No callback, no event bus, no
manual reload call written by you anywhere in this chain.