In the previous tutorial, we used SQLDelight for structured data — tables with rows and columns. But not everything belongs in a database. Simple settings like “dark mode on/off”, “last selected tab”, or “auth token” need something lighter.
That is what DataStore does. It is Google’s replacement for SharedPreferences, and it works on Android, iOS, and Desktop through KMP.
What is DataStore?
DataStore is a key-value storage library. Think of it like a dictionary:
"dark_mode" → true
"language" → "en"
"auth_token" → "eyJhbG..."
"onboarding_completed" → true
"last_sync_time" → 1710432000000
Simple keys, simple values. No tables, no SQL, no schema.
DataStore vs SharedPreferences vs SQLDelight
| SharedPreferences | DataStore | SQLDelight | |
|---|---|---|---|
| Platform | Android only | Android, iOS, Desktop (KMP) | Android, iOS, Desktop (KMP) |
| Data type | Key-value | Key-value | Structured tables |
| Thread safe | No (can cause ANR) | Yes (coroutines + Flow) | Yes (coroutines + Flow) |
| Reactive | No | Yes (Flow) | Yes (Flow) |
| Use for | Simple settings | Simple settings (KMP) | Complex data with relationships |
| Type safe | No | Yes (typed keys) | Yes (generated from SQL) |
Rule of thumb: Use DataStore for settings and preferences. Use SQLDelight for lists, records, and anything with relationships.
Setup
Dependencies
# gradle/libs.versions.toml
[versions]
datastore = "1.2.1"
[libraries]
datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
// shared/build.gradle.kts
sourceSets {
commonMain.dependencies {
implementation(libs.datastore)
implementation(libs.datastore.preferences)
}
}
No platform-specific dependencies needed — DataStore handles it internally.
Step 1: Create the DataStore Instance
DataStore needs a file path, which is different on each platform. This is a classic expect/actual use case.
Common Code
// shared/src/commonMain/kotlin/storage/DataStoreFactory.kt
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import okio.Path.Companion.toPath
// Common factory — each platform provides the file path
fun createDataStore(producePath: () -> String): DataStore<Preferences> {
return PreferenceDataStoreFactory.createWithPath(
produceFile = { producePath().toPath() }
)
}
// Consistent file name across all platforms
internal const val DATA_STORE_FILE = "app_settings.preferences_pb"
Android
// shared/src/androidMain/kotlin/storage/DataStoreFactory.android.kt
import android.content.Context
fun createDataStore(context: Context): DataStore<Preferences> {
return createDataStore(
producePath = {
context.filesDir.resolve(DATA_STORE_FILE).absolutePath
}
)
}
iOS
// shared/src/iosMain/kotlin/storage/DataStoreFactory.ios.kt
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask
fun createDataStore(): DataStore<Preferences> {
return createDataStore(
producePath = {
val directory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
requireNotNull(directory).path + "/$DATA_STORE_FILE"
}
)
}
Same DATA_STORE_FILE name. Different path. DataStore handles the rest.
Step 2: Define Typed Keys
DataStore uses typed keys — not raw strings. This prevents bugs like storing a String where an Int is expected:
// shared/src/commonMain/kotlin/storage/PreferenceKeys.kt
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
// Type-safe keys — the compiler prevents wrong types
object PreferenceKeys {
val DARK_MODE = booleanPreferencesKey("dark_mode")
val LANGUAGE = stringPreferencesKey("language")
val AUTH_TOKEN = stringPreferencesKey("auth_token")
val ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed")
val LAST_SYNC_TIME = longPreferencesKey("last_sync_time")
val NOTIFICATION_COUNT = intPreferencesKey("notification_count")
val FONT_SIZE = intPreferencesKey("font_size")
}
Available key types:
booleanPreferencesKey()— true/falsestringPreferencesKey()— textintPreferencesKey()— whole numberslongPreferencesKey()— large numbers (timestamps)floatPreferencesKey()— decimal numbersdoublePreferencesKey()— precise decimal numbersstringSetPreferencesKey()— set of strings
Step 3: Create a Settings Repository
Wrap DataStore in a repository with clean read/write functions:
// shared/src/commonMain/kotlin/storage/SettingsRepository.kt
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
// --- READ (as Flow — updates automatically when value changes) ---
val isDarkMode: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[PreferenceKeys.DARK_MODE] ?: false
}
val language: Flow<String> = dataStore.data.map { preferences ->
preferences[PreferenceKeys.LANGUAGE] ?: "en"
}
val authToken: Flow<String?> = dataStore.data.map { preferences ->
preferences[PreferenceKeys.AUTH_TOKEN]
}
val isOnboardingCompleted: Flow<Boolean> = dataStore.data.map { preferences ->
preferences[PreferenceKeys.ONBOARDING_COMPLETED] ?: false
}
val fontSize: Flow<Int> = dataStore.data.map { preferences ->
preferences[PreferenceKeys.FONT_SIZE] ?: 16
}
// --- WRITE ---
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[PreferenceKeys.DARK_MODE] = enabled
}
}
suspend fun setLanguage(language: String) {
dataStore.edit { preferences ->
preferences[PreferenceKeys.LANGUAGE] = language
}
}
suspend fun setAuthToken(token: String?) {
dataStore.edit { preferences ->
if (token != null) {
preferences[PreferenceKeys.AUTH_TOKEN] = token
} else {
preferences.remove(PreferenceKeys.AUTH_TOKEN)
}
}
}
suspend fun setOnboardingCompleted() {
dataStore.edit { preferences ->
preferences[PreferenceKeys.ONBOARDING_COMPLETED] = true
}
}
suspend fun setFontSize(size: Int) {
dataStore.edit { preferences ->
preferences[PreferenceKeys.FONT_SIZE] = size
}
}
// --- CLEAR ALL ---
suspend fun clearAll() {
dataStore.edit { preferences ->
preferences.clear()
}
}
}
Key patterns:
- Read uses
dataStore.data.map { }→ returns aFlowthat auto-updates - Write uses
dataStore.edit { }→ suspend function, runs on background thread - Default values use
?: defaultValuefor when the key doesn’t exist yet - Remove uses
preferences.remove(key)for optional values like auth tokens
Step 4: Use in ViewModel
// shared/src/commonMain/kotlin/viewmodel/SettingsViewModel.kt
class SettingsViewModel(private val settings: SettingsRepository) : ViewModel() {
// Collect settings as state
val isDarkMode = settings.isDarkMode
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val language = settings.language
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "en")
val fontSize = settings.fontSize
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 16)
// Write settings
fun toggleDarkMode() {
viewModelScope.launch {
settings.setDarkMode(!isDarkMode.value)
}
}
fun changeLanguage(lang: String) {
viewModelScope.launch {
settings.setLanguage(lang)
}
}
fun changeFontSize(size: Int) {
viewModelScope.launch {
settings.setFontSize(size)
}
}
fun logout() {
viewModelScope.launch {
settings.setAuthToken(null)
}
}
}
stateIn() converts a Flow to a StateFlow that Compose can observe. SharingStarted.WhileSubscribed(5000) keeps the flow active for 5 seconds after the last subscriber leaves — prevents re-reading on quick configuration changes.
Step 5: Use in Compose UI
@Composable
fun SettingsScreen(viewModel: SettingsViewModel) {
val isDarkMode by viewModel.isDarkMode.collectAsStateWithLifecycle()
val language by viewModel.language.collectAsStateWithLifecycle()
val fontSize by viewModel.fontSize.collectAsStateWithLifecycle()
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Settings", style = MaterialTheme.typography.headlineLarge)
// Dark mode toggle
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("Dark Mode", modifier = Modifier.weight(1f))
Switch(
checked = isDarkMode,
onCheckedChange = { viewModel.toggleDarkMode() }
)
}
// Language selector
Text("Language: $language")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("en", "de", "tr", "es").forEach { lang ->
FilterChip(
selected = language == lang,
onClick = { viewModel.changeLanguage(lang) },
label = { Text(lang.uppercase()) }
)
}
}
// Font size slider
Text("Font Size: ${fontSize}sp")
Slider(
value = fontSize.toFloat(),
onValueChange = { viewModel.changeFontSize(it.toInt()) },
valueRange = 12f..32f
)
// Preview
Text(
"Preview text at ${fontSize}sp",
fontSize = fontSize.sp
)
}
}
Toggle dark mode → DataStore saves it → Flow emits new value → UI updates. All automatic. Works on Android AND iOS.
Practical Example: Auth Token Management
A common real-world use case — storing and checking the authentication token:
class AuthRepository(private val settings: SettingsRepository) {
// Check if user is logged in
val isLoggedIn: Flow<Boolean> = settings.authToken.map { token ->
token != null && token.isNotBlank()
}
// Save token after login
suspend fun login(token: String) {
settings.setAuthToken(token)
settings.setOnboardingCompleted()
}
// Clear token on logout
suspend fun logout() {
settings.setAuthToken(null)
}
// Get token for API calls
val authToken: Flow<String?> = settings.authToken
}
// In your app's root composable
@Composable
fun AppRoot(authRepo: AuthRepository) {
val isLoggedIn by authRepo.isLoggedIn.collectAsStateWithLifecycle(initialValue = false)
if (isLoggedIn) {
MainScreen()
} else {
LoginScreen()
}
}
When the user logs in, the token is saved to DataStore. When they log out, it is removed. The isLoggedIn Flow automatically switches the UI between LoginScreen and MainScreen.
DataStore vs SQLDelight — When to Use Which
| Data | Use DataStore | Use SQLDelight |
|---|---|---|
| Dark mode on/off | ✅ | Overkill |
| Auth token | ✅ | Overkill |
| Language preference | ✅ | Overkill |
| Font size | ✅ | Overkill |
| List of notes | Too complex | ✅ |
| User profiles | Too complex | ✅ |
| Shopping cart items | Too complex | ✅ |
| Search history | Could work, but… | ✅ Better |
Simple rule: If it is a single value with a key → DataStore. If it is a list of items with relationships → SQLDelight.
Common Mistakes
Mistake 1: Creating Multiple DataStore Instances
// BAD — creates a new DataStore every time, causes crashes
fun getSettings(): SettingsRepository {
val dataStore = createDataStore(context)
return SettingsRepository(dataStore)
}
// GOOD — create ONE DataStore, reuse everywhere
val dataStore = createDataStore(context)
val settings = SettingsRepository(dataStore)
DataStore files lock when opened. Multiple instances = crash.
Mistake 2: Blocking the Main Thread
// BAD — blocks the UI thread
val darkMode = runBlocking { settings.isDarkMode.first() }
// GOOD — collect in a coroutine
viewModelScope.launch {
settings.isDarkMode.collect { isDark ->
_state.update { it.copy(isDarkMode = isDark) }
}
}
Mistake 3: Not Providing Default Values
// BAD — returns null, crashes later
val fontSize: Flow<Int> = dataStore.data.map { it[FONT_SIZE]!! }
// GOOD — safe default
val fontSize: Flow<Int> = dataStore.data.map { it[FONT_SIZE] ?: 16 }
Mistake 4: Storing Complex Objects
// BAD — DataStore is for simple values
val user: Flow<User> = ... // Can't store objects in Preferences DataStore
// GOOD — store individual values, or use SQLDelight for objects
val userName: Flow<String> = dataStore.data.map { it[USER_NAME] ?: "" }
Quick Reference
| Action | Code |
|---|---|
| Create key | booleanPreferencesKey("key_name") |
| Read value | dataStore.data.map { it[KEY] ?: default } |
| Write value | dataStore.edit { it[KEY] = value } |
| Remove value | dataStore.edit { it.remove(KEY) } |
| Clear all | dataStore.edit { it.clear() } |
| Observe as StateFlow | flow.stateIn(scope, SharingStarted.WhileSubscribed(5000), default) |
Source Code
The KMP tutorial project is on GitHub:
Related Tutorials
- KMP Tutorial #7: SQLDelight — structured database for complex data
- KMP Tutorial #3: Project Structure — expect/actual pattern used for DataStore paths
- Compose Tutorial #5: State — state management that DataStore builds on
What’s Next?
In the next tutorial, we will learn about Koin — dependency injection for KMP. Instead of manually creating DataStore, SQLDelight, and Ktor instances, Koin will wire everything together automatically.
See you there.