SHAKIL.DEV

SHAKIL.DEV

Save Data on the Phone with Room Database (Android Beginners)
โ† BACK TO TRANSMISSIONS
October 19, 2026

Save Data on the Phone with Room Database (Android Beginners)

Save data on the phone with Room. Build a Compose shopping list that works offline and is still there after you close the app.

In Part 14 our app learned to show Loading, Error and Retry screens. But there is still one big problem: when you close the app, everything you typed is gone. Today we fix that with Room database. Room lets your app save data on the phone itself, so the data is still there tomorrow, even without internet. We will build a small Shopping List screen step by step. ๐Ÿ›’

Why Save Data on the Phone?

Think about a shopping list on paper. You write "Rice, 2 kg" and put the paper in your pocket. Tomorrow the paper is still there. An app should work the same way.

Saving data locally (on the phone) gives you two big wins:

  • It works offline. No internet? No problem. The data lives on the phone.
  • It survives a restart. Close the app, restart the phone, and the list is still there.

So far our data lived only in a ViewModel. A ViewModel is like a whiteboard: very useful, but it gets wiped when the app is fully closed. A database is like a notebook. It stays.

What Is a Database, Table and Row?

Imagine an Excel or Google Sheets file:

  • The database is the whole file.
  • A table is one sheet inside the file, for example "ShoppingItem".
  • A column is a heading like name or quantity.
  • A row is one line of data, like "Rice, 2, not bought".
idnamequantitybought
1Rice2false
2Eggs12true

Android has a database built in called SQLite. Talking to SQLite directly is hard work. Room is a helper library from Google that makes it easy. You write simple Kotlin, and Room writes the hard database code for you.

Room has three main parts:

  1. Entity โ€“ what one row looks like (our table).
  2. DAO (Data Access Object) โ€“ the list of things you can do: add, update, delete, read.
  3. Database โ€“ the main class that holds everything together.

Step 1: Find Your Kotlin Version

Room needs a tool called KSP (Kotlin Symbol Processing). KSP reads your Room code and generates the hidden database code during the build.

First, check which Kotlin version your project uses. Open gradle/libs.versions.toml (in the Project view, under "Gradle Scripts" it is called libs.versions.toml). Look for a line like this:

[versions]
kotlin = "2.3.20"

That number is your Kotlin version. Remember it.

Tip: If you don't have a libs.versions.toml file, look in your top-level build.gradle.kts for a line like id("org.jetbrains.kotlin.android") version "...". That version is your Kotlin version.

Step 2: Add KSP and Room

A) Top-level build.gradle.kts (the one in the project root, not inside app). Add the KSP line to the plugins { } block:

plugins {
    // ...keep the lines that are already here
    id("com.google.devtools.ksp") version "2.3.12" apply false
}

B) app/build.gradle.kts. Add KSP to the plugins { } block:

plugins {
    // ...keep the lines that are already here
    id("com.google.devtools.ksp")
}

Then add Room to the dependencies { } block:

dependencies {
    // ...your other dependencies

    implementation("androidx.room:room-runtime:2.8.5")
    ksp("androidx.room:room-compiler:2.8.5")
}

Click Sync Now at the top of the editor.

Let's understand it:

  • room-runtime is the Room library your app uses.
  • room-compiler is added with ksp(...), not implementation(...). It only runs while building. It writes code for you.
  • In older tutorials you will also see room-ktx. Since Room 2.7, everything from room-ktx (like suspend and Flow support) is inside room-runtime. You don't need room-ktx any more.

Tip: KSP version names changed. Since KSP 2.3.0, the KSP version is not tied to the Kotlin version any more, so 2.3.12 is just "the latest KSP". It needs a modern setup: a recent Kotlin 2.x version and Android Gradle Plugin 8.12 or newer. A new project from a current Android Studio is fine. If your Kotlin version is old (like 2.0.21), the easy fix is to update Kotlin. Android Studio may also suggest newer versions of Room and KSP; accepting them is fine.

Tip: You may also hear about Room 3 (androidx.room3). It is a new major version made mainly for Kotlin Multiplatform, and it needs extra setup. Room 2.8 is still fully supported and is the best choice for learning. Everything you learn here carries over.

Step 3: Create the Entity (Our Table)

Right-click your main package โ†’ New โ†’ Kotlin Class/File โ†’ name it ShoppingItem.

package com.example.myfirstapp

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity
data class ShoppingItem(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val name: String,
    val quantity: Int,
    val bought: Boolean = false
)

Let's understand it:

  • @Entity tells Room: "Make a table from this class." The table name is ShoppingItem.
  • Each property becomes a column.
  • @PrimaryKey is the unique ID of each row, like a roll number in class. No two rows can have the same one.
  • autoGenerate = true means Room picks the number for you: 1, 2, 3... We pass 0 and Room replaces it.

Step 4: Create the DAO

Create a new file ShoppingDao.kt:

package com.example.myfirstapp

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

@Dao
interface ShoppingDao {

    @Insert
    suspend fun insert(item: ShoppingItem)

    @Update
    suspend fun update(item: ShoppingItem)

    @Delete
    suspend fun delete(item: ShoppingItem)

    @Query("SELECT * FROM ShoppingItem ORDER BY id DESC")
    fun getAll(): Flow<List<ShoppingItem>>
}

Let's understand it:

  • @Dao is the "menu" of actions for our table.
  • @Insert, @Update and @Delete are ready-made. You don't write any SQL for them.
  • They are suspend functions. Saving to disk takes time, so Room does it in the background and the screen never freezes. (Want to learn more? See our Kotlin coroutines guide.)
  • @Query uses a little SQL. SELECT * FROM ShoppingItem ORDER BY id DESC means "give me all rows, newest first".
  • getAll() returns a Flow. A Flow is like a live TV channel: every time the table changes, a new list arrives automatically. That is why getAll() is not suspend.

Step 5: Create the Database

Create a new file ShoppingDatabase.kt:

package com.example.myfirstapp

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(entities = [ShoppingItem::class], version = 1, exportSchema = false)
abstract class ShoppingDatabase : RoomDatabase() {

    abstract fun shoppingDao(): ShoppingDao

    companion object {
        @Volatile
        private var INSTANCE: ShoppingDatabase? = null

        fun getDatabase(context: Context): ShoppingDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    ShoppingDatabase::class.java,
                    "shopping_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Let's understand it:

  • @Database lists all tables (entities) and a version number. Start with 1.
  • abstract fun shoppingDao() โ€“ Room writes the real code for this. You never write it.
  • getDatabase() is a singleton. That means the app only ever creates one database object. Opening many copies would be slow and could cause bugs.
  • "shopping_database" is the name of the file saved on the phone.
  • exportSchema = false just turns off a warning we don't need yet.

Step 6: Create the ViewModel

The ViewModel needs the DAO. And to get the DAO, it needs a Context. The simplest way for beginners is AndroidViewModel, which gives us the application (a safe Context).

Create ShoppingViewModel.kt:

package com.example.myfirstapp

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class ShoppingViewModel(application: Application) : AndroidViewModel(application) {

    private val dao = ShoppingDatabase.getDatabase(application).shoppingDao()

    val items: StateFlow<List<ShoppingItem>> = dao.getAll()
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun addItem(name: String, quantity: Int) {
        if (name.isBlank()) return
        viewModelScope.launch {
            dao.insert(ShoppingItem(name = name.trim(), quantity = quantity))
        }
    }

    fun toggleBought(item: ShoppingItem) {
        viewModelScope.launch {
            dao.update(item.copy(bought = !item.bought))
        }
    }

    fun deleteItem(item: ShoppingItem) {
        viewModelScope.launch {
            dao.delete(item)
        }
    }
}

Let's understand it:

  • stateIn(...) turns Room's Flow into a StateFlow, the same kind we used in Part 9.
  • initialValue = emptyList() โ€“ the list starts empty for a tiny moment, until Room loads the data.
  • WhileSubscribed(5000) means: keep listening while the screen is visible, and wait 5 seconds before stopping. If you rotate the phone, the data doesn't reload from zero.
  • viewModelScope.launch { } starts a background job to call our suspend DAO functions.
  • item.copy(bought = !item.bought) makes a copy with only bought flipped. Because the id is the same, Room knows which row to update.

Tip: Bigger apps don't use AndroidViewModel. They use dependency injection (like Hilt) to give the DAO to the ViewModel. That makes testing easier. When you're ready, read Clean Architecture in Android. For learning, AndroidViewModel is perfect.

Step 7: Build the Shopping List Screen

Create ShoppingScreen.kt:

package com.example.myfirstapp

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun ShoppingScreen(
    modifier: Modifier = Modifier,
    viewModel: ShoppingViewModel = viewModel()
) {
    val items by viewModel.items.collectAsStateWithLifecycle()
    var name by rememberSaveable { mutableStateOf("") }

    Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            OutlinedTextField(
                value = name,
                onValueChange = { name = it },
                label = { Text("Item name") },
                singleLine = true,
                modifier = Modifier.weight(1f)
            )
            Spacer(Modifier.width(8.dp))
            Button(onClick = {
                viewModel.addItem(name, quantity = 1)
                name = ""
            }) {
                Text("Add")
            }
        }

        if (items.isEmpty()) {
            Text("Your list is empty. Add something!", Modifier.padding(top = 24.dp))
        }

        LazyColumn(
            modifier = Modifier.padding(top = 16.dp),
            verticalArrangement = Arrangement.spacedBy(4.dp)
        ) {
            items(items, key = { it.id }) { item ->
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Checkbox(
                        checked = item.bought,
                        onCheckedChange = { viewModel.toggleBought(item) }
                    )
                    Text(
                        text = "${item.name} ร— ${item.quantity}",
                        textDecoration = if (item.bought) TextDecoration.LineThrough else null,
                        modifier = Modifier.weight(1f)
                    )
                    IconButton(onClick = { viewModel.deleteItem(item) }) {
                        Icon(Icons.Default.Delete, contentDescription = "Delete ${item.name}")
                    }
                }
            }
        }
    }
}

Let's understand it:

  • collectAsStateWithLifecycle() reads the list, just like in Part 9.
  • When you tap Add, the ViewModel saves to Room. Room's Flow sends a new list. The screen updates by itself. You never "refresh" anything.
  • Bought items get a line through them with TextDecoration.LineThrough.
  • key = { it.id } helps Compose know which row is which.

Use Alt+Enter on any red word to add imports. The tricky ones are:

  • androidx.compose.foundation.lazy.items (not the one with Index)
  • androidx.compose.material.icons.filled.Delete
  • androidx.lifecycle.compose.collectAsStateWithLifecycle
  • androidx.lifecycle.viewmodel.compose.viewModel

Step 8: Show the Screen

Add ShoppingScreen as a new tab in your bottom bar from Part 10, or as a new route like "shopping" in your NavHost from Part 8. For a quick test, you can call it straight from MainActivity:

Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
    ShoppingScreen(modifier = Modifier.padding(innerPadding))
}

Run the app. Add "Rice", "Eggs" and "Mango". Tick one. Now close the app completely (swipe it away from recent apps) and open it again. Your list is still there! ๐ŸŽ‰

Common Errors

"Unresolved reference: ksp" โ€“ You added ksp(...) in dependencies but forgot id("com.google.devtools.ksp") in the plugins { } block of app/build.gradle.kts. Add it and sync.

"Plugin [id: 'com.google.devtools.ksp'] was not found" โ€“ The top-level build.gradle.kts is missing the line with the version number. Add id("com.google.devtools.ksp") version "2.3.12" apply false.

KSP build fails with a Kotlin or AGP version message โ€“ Your Kotlin or Android Gradle Plugin is too old for this KSP. Update Kotlin in libs.versions.toml (Android Studio usually highlights it in yellow with a newer version). Old projects can use the old style name like 2.0.21-1.0.28, where the first part must match your Kotlin version exactly.

"Cannot find implementation for ShoppingDatabase" โ€“ Room's compiler did not run. Check that you used ksp("androidx.room:room-compiler:2.8.5"), not implementation(...), then do Build โ†’ Rebuild Project.

"Unresolved reference: Delete" or "Icons" โ€“ Newer Compose versions don't include icons by default. Add implementation("androidx.compose.material:material-icons-core") (the Compose BOM picks the version) and sync.

App crashes after you change ShoppingItem โ€“ You added a column, but the old database on the phone has the old shape. While learning, the easiest fix is to uninstall the app from the emulator and run again. Real apps use a "migration".

Try It Yourself

  1. Add a second text field for quantity, so the user can type "3". Hint: use toIntOrNull() ?: 1.
  2. Add a "Clear bought items" button. Hint: write @Query("DELETE FROM ShoppingItem WHERE bought = 1") as a suspend fun in the DAO.
  3. Show a count at the top: "3 items, 1 bought".

Frequently Asked Questions

Do I need the internet to use Room? No. Room saves everything in a file on the phone. It works fully offline.

Where is the data saved? Can I see it? It's inside your app's private storage. In Android Studio, open View โ†’ Tool Windows โ†’ App Inspection โ†’ Database Inspector while the app runs. You can see your table like a spreadsheet!

What happens if the user uninstalls the app? The database is deleted with the app. If you need data to survive that, you need a server or cloud backup.

Quick Recap

  • Room saves data on the phone, so it works offline and survives restarts.
  • A database is like a spreadsheet file; a table is a sheet; a row is one line.
  • Entity = table, DAO = actions, Database = the holder.
  • Use Room 2.8.5 with room-runtime and ksp(room-compiler). room-ktx is no longer needed.
  • A Flow from Room updates the screen automatically.
  • AndroidViewModel is an easy way to get the DAO; big apps use Hilt.

Next, we'll save small settings like dark mode and the user's name with DataStore.

Part 16: Save Settings in Android with DataStore (Dark Mode Switch)

Next in Android for Beginners โ†’
Save Settings in Android with DataStore (Dark Mode Switch)