Persist data on the device: Room gives you a typed SQLite database with reactive queries, and DataStore stores key-value preferences safely. Plus where SharedPreferences fits (and why to move on).
Room is the standard local database, a type-safe layer over SQLite. You define tables as @Entity data classes and data-access methods as a @Dao interface, and Room generates the implementation and verifies your SQL at compile time. It removes the boilerplate and stringly-typed errors of raw SQLite while giving you the full power of a relational database on the device.
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: Int,
val name: String,
val email: String
)
@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(users: List<UserEntity>) // suspend -> off the main thread
@Query("SELECT * FROM users WHERE id = :id")
suspend fun getById(id: Int): UserEntity? // SQL verified at COMPILE time
}
// Room generates the implementation. Wrong column name in the @Query? Build error.Room’s superpower for modern apps is returning a Flow from a query: the Flow emits the current results and re-emits automatically whenever the underlying table changes. This is what enables the offline-first repository — the UI observes a database Flow and updates the instant any write occurs, with no manual refresh. Database becomes the reactive single source of truth.
@Dao
interface UserDao {
// returns a Flow: emits now, and AGAIN every time the users table changes
@Query("SELECT * FROM users ORDER BY name")
fun observeAll(): Flow<List<UserEntity>>
}
// in the repository -> ViewModel -> UI:
// dao.observeAll() ... collectAsState() in Compose
// any insert/update/delete anywhere -> the Flow re-emits -> UI updates itself.
// this is the engine behind offline-first (repository lesson).For small key-value data — settings, flags, a saved theme — DataStore is the modern replacement for SharedPreferences. It is asynchronous (Flow-based, no blocking the main thread), transactional, and type-safe with Proto DataStore. Reading returns a Flow so preference changes propagate reactively, fitting the same pattern as the rest of the app.
val Context.dataStore by preferencesDataStore(name = "settings")
private val DARK_MODE = booleanPreferencesKey("dark_mode")
// read as a Flow — updates propagate reactively, off the main thread
val darkModeFlow: Flow<Boolean> = context.dataStore.data
.map { prefs -> prefs[DARK_MODE] ?: false }
// write in a transaction
suspend fun setDarkMode(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[DARK_MODE] = enabled }
}
// DataStore replaces SharedPreferences: async, safe, Flow-based, transactional.Match the tool to the data: Room for structured, queryable, relational data (a list of records you filter and sort); DataStore for small key-value settings; the file system for large binary blobs like images or downloads. SharedPreferences still exists but DataStore supersedes it. Picking the right store keeps data access simple and correct.
Which local storage?
structured / relational / queryable -> Room (records, lists, relations)
"a table of users I filter + sort, observed as a Flow"
small key-value settings -> DataStore (theme, flags, onboarding-done)
large binary blobs -> the file system (images, video, downloads)
(store the FILE on disk, the PATH in Room)
legacy small key-value -> SharedPreferences (prefer DataStore now)
For anything sensitive (tokens, keys) add encryption — never store secrets
in plain preferences.