Your app loads data from an API. But what happens when the user has no internet? The screen goes blank.

Room database fixes this. It saves data on the device so your app works offline. And it integrates perfectly with Compose — when data changes in the database, the UI updates automatically.

What is Room?

Room is Google’s database library for Android. It sits on top of SQLite and gives you a clean Kotlin API instead of raw SQL.

Three main parts:

PartWhat It IsWhat It Does
EntityA data classDefines a database table
DAOAn interfaceDefines how to read/write data
DatabaseAn abstract classCreates and manages the database
Entity (data) → DAO (operations) → Database (connection)
       ↕              ↕
    Kotlin          Compose UI

Setup

Add Room dependencies to your build.gradle.kts:

plugins {
    // Add KSP for Room annotation processing
    id("com.google.devtools.ksp") version "2.2.10-1.0.30"
}

dependencies {
    val roomVersion = "2.7.1"
    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion")
    ksp("androidx.room:room-compiler:$roomVersion")
}

Step 1: Create the Entity

An Entity is a regular Kotlin data class with annotations that tell Room how to create the table:

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val title: String,
    val content: String,
    val createdAt: Long = System.currentTimeMillis()
)

This creates a table called notes with four columns: id, title, content, and createdAt. The id auto-generates — you don’t need to set it.

Step 2: Create the DAO

The DAO (Data Access Object) defines what you can do with the data:

@Dao
interface NoteDao {
    // Get all notes, newest first
    // Flow means Compose updates automatically when data changes
    @Query("SELECT * FROM notes ORDER BY createdAt DESC")
    fun getAllNotes(): Flow<List<Note>>

    // Get one note by ID
    @Query("SELECT * FROM notes WHERE id = :noteId")
    suspend fun getNoteById(noteId: Int): Note?

    // Insert a new note
    @Insert
    suspend fun insert(note: Note)

    // Update an existing note
    @Update
    suspend fun update(note: Note)

    // Delete a note
    @Delete
    suspend fun delete(note: Note)

    // Search notes by title
    @Query("SELECT * FROM notes WHERE title LIKE '%' || :query || '%' ORDER BY createdAt DESC")
    fun searchNotes(query: String): Flow<List<Note>>
}

Key point: getAllNotes() returns Flow<List<Note>>. This means whenever you add, update, or delete a note, the list updates automatically. No manual refresh needed.

Functions that change data (insert, update, delete) are suspend — they run on a background thread.

Step 3: Create the Database

@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao
}

Create a singleton to get the database instance:

object DatabaseProvider {
    @Volatile
    private var INSTANCE: AppDatabase? = null

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

Step 4: Create the ViewModel

The ViewModel connects the database to the UI:

data class NotesState(
    val notes: List<Note> = emptyList(),
    val searchQuery: String = ""
)

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

    private val dao = DatabaseProvider.getDatabase(application).noteDao()

    private val _state = MutableStateFlow(NotesState())
    val state: StateFlow<NotesState> = _state.asStateFlow()

    init {
        // Observe all notes — UI updates automatically when database changes
        viewModelScope.launch {
            dao.getAllNotes().collect { notes ->
                _state.update { it.copy(notes = notes) }
            }
        }
    }

    fun addNote(title: String, content: String) {
        viewModelScope.launch {
            dao.insert(Note(title = title, content = content))
            // No need to manually refresh — Flow handles it
        }
    }

    fun deleteNote(note: Note) {
        viewModelScope.launch {
            dao.delete(note)
        }
    }

    // Track the search job so we can cancel the previous one
    private var searchJob: Job? = null

    fun onSearchQueryChange(query: String) {
        _state.update { it.copy(searchQuery = query) }

        // Cancel the previous search before starting a new one
        // Without this, multiple collectors run in parallel
        searchJob?.cancel()
        searchJob = viewModelScope.launch {
            val flow = if (query.isEmpty()) {
                dao.getAllNotes()
            } else {
                dao.searchNotes(query)
            }
            flow.collect { notes ->
                _state.update { it.copy(notes = notes) }
            }
        }
    }
}

Notice: after insert or delete, we don’t manually update the state. Room’s Flow detects the change and emits the new list automatically. The collect block in init catches it and updates the UI.

Step 5: Build the UI

@Composable
fun NotesScreen(viewModel: NotesViewModel = viewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    var showAddDialog by remember { mutableStateOf(false) }

    Scaffold(
        floatingActionButton = {
            FloatingActionButton(onClick = { showAddDialog = true }) {
                Icon(Icons.Default.Add, contentDescription = "Add note")
            }
        }
    ) { padding ->
        Column(modifier = Modifier.padding(padding).fillMaxSize()) {
            // Search bar
            OutlinedTextField(
                value = state.searchQuery,
                onValueChange = { viewModel.onSearchQueryChange(it) },
                label = { Text("Search notes") },
                modifier = Modifier.fillMaxWidth().padding(16.dp)
            )

            // Notes list
            if (state.notes.isEmpty()) {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Text("No notes yet. Tap + to add one.")
                }
            } else {
                LazyColumn(
                    contentPadding = PaddingValues(16.dp),
                    verticalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    items(state.notes, key = { it.id }) { note ->
                        NoteCard(
                            note = note,
                            onDelete = { viewModel.deleteNote(note) }
                        )
                    }
                }
            }
        }
    }

    // Add note dialog
    if (showAddDialog) {
        AddNoteDialog(
            onDismiss = { showAddDialog = false },
            onSave = { title, content ->
                viewModel.addNote(title, content)
                showAddDialog = false
            }
        )
    }
}

Note Card

@Composable
fun NoteCard(note: Note, onDelete: () -> Unit) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        shape = RoundedCornerShape(12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.Top
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(note.title, fontWeight = FontWeight.Bold)
                if (note.content.isNotEmpty()) {
                    Text(
                        note.content,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
            }
            IconButton(onClick = onDelete) {
                Icon(Icons.Default.Delete, contentDescription = "Delete")
            }
        }
    }
}

Add Note Dialog

@Composable
fun AddNoteDialog(onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
    var title by remember { mutableStateOf("") }
    var content by remember { mutableStateOf("") }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("New Note") },
        text = {
            Column {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("Title") },
                    modifier = Modifier.fillMaxWidth()
                )
                Spacer(modifier = Modifier.height(8.dp))
                OutlinedTextField(
                    value = content,
                    onValueChange = { content = it },
                    label = { Text("Content") },
                    modifier = Modifier.fillMaxWidth(),
                    minLines = 3
                )
            }
        },
        confirmButton = {
            Button(
                onClick = { onSave(title, content) },
                enabled = title.isNotBlank()
            ) {
                Text("Save")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancel")
            }
        }
    )
}

How Flow + Room + Compose Work Together

This is the most important pattern to understand:

Database changes (insert/update/delete)
Room's Flow detects the change
Flow emits the new list
ViewModel collects it and updates StateFlow
Compose observes StateFlow and redraws the UI
User sees the updated data instantly

You don’t call any “refresh” function. The data flows automatically from database to screen. This is called reactive data flow — and it is the standard pattern for modern Android apps.

Common Mistakes

Mistake 1: Not Using Flow for Queries

// BAD — you need to manually refresh after every change
@Query("SELECT * FROM notes")
suspend fun getAllNotes(): List<Note>

// GOOD — automatically emits new data when table changes
@Query("SELECT * FROM notes")
fun getAllNotes(): Flow<List<Note>>

Mistake 2: Running Database Operations on Main Thread

// BAD — crashes with "Cannot access database on the main thread"
val notes = dao.getAllNotes()

// GOOD — use suspend functions + viewModelScope
viewModelScope.launch {
    dao.insert(note)
}

Mistake 3: Creating Multiple Database Instances

// BAD — creates a new database every time
val db = Room.databaseBuilder(...).build()

// GOOD — use a singleton
val db = DatabaseProvider.getDatabase(context)

Mistake 4: Forgetting KSP Plugin

If you get “cannot find implementation for database” error, make sure you added the KSP plugin and the Room compiler dependency.

Quick Reference

AnnotationWhat It Does
@EntityMarks a class as a database table
@PrimaryKeyMarks the unique ID column
@DaoMarks an interface as a Data Access Object
@QueryCustom SQL query
@InsertInsert a row
@UpdateUpdate a row
@DeleteDelete a row
@DatabaseMarks the database class

Result

Here is what the notes app looks like when you run the code from this tutorial:

Light ModeDark Mode
Tutorial 13 LightTutorial 13 Dark

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Hilt — Dependency Injection. Instead of creating the database manually with DatabaseProvider, Hilt will inject it automatically. This makes your code cleaner and easier to test.

See you there.