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.

Where this fits: This assumes the Room concepts (@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.

Note: This is correct, real Compose/Room/Kotlin code — but this website's own project doesn't run a live Android Studio build, so no query actually executes on this page.
Next up: Local Room data is only half the picture — RecipeBox's community recipes come from a network API. Retrofit & Data Loading Patterns covers the Loading/Success/Error states a network call needs that a local database mostly doesn't.