A Kotlin Multiplatform library that provides a thin, convenient wrapper around AndroidX DataStore Preferences and Proto DataStore.
Inspired by flow-preferences for SharedPreferences.
| Module | Description |
|---|---|
generic-datastore |
Published umbrella artifact that depends on generic-datastore-preferences and generic-datastore-proto |
generic-datastore-preferences |
Published Preferences DataStore wrapper module |
generic-datastore-proto |
Published Proto DataStore wrapper module |
generic-datastore-core |
Shared internal core module used by the feature modules |
generic-datastore-compose |
Published Compose helpers built on top of core and preferences |
The library modules target Android, Desktop (JVM), and iOS (iosArm64,
iosSimulatorArm64).
Add the JitPack repository to your settings.gradle.kts:
dependencyResolutionManagement {
repositories {
maven("https://jitpack.io")
}
}Add the dependencies:
dependencies {
implementation("com.github.ArthurKun21:generic-datastore:<version>")
// Or choose a feature module directly
implementation("com.github.ArthurKun21:generic-datastore-preferences:<version>")
implementation("com.github.ArthurKun21:generic-datastore-proto:<version>")
implementation("com.github.ArthurKun21:generic-datastore-compose:<version>")
}Add the GitHub Packages repository to your settings.gradle.kts. Authentication requires a GitHub
personal access token (classic) with the read:packages scope:
dependencyResolutionManagement {
repositories {
maven {
url = uri("https://maven.pkg.github.com/ArthurKun21/generic-datastore")
credentials {
username = providers.gradleProperty("gpr.user").orNull
?: System.getenv("GITHUB_ACTOR")
password = providers.gradleProperty("gpr.key").orNull
?: System.getenv("GITHUB_TOKEN")
}
}
}
}Set the credentials in your ~/.gradle/gradle.properties (or use the environment variables
GITHUB_ACTOR and GITHUB_TOKEN):
gpr.user=YOUR_GITHUB_USERNAME
gpr.key=YOUR_GITHUB_TOKENAdd the dependencies:
dependencies {
implementation("com.github.ArthurKun21:generic-datastore:<version>")
// Or choose a feature module directly
implementation("com.github.ArthurKun21:generic-datastore-preferences:<version>")
implementation("com.github.ArthurKun21:generic-datastore-proto:<version>")
implementation("com.github.ArthurKun21:generic-datastore-compose:<version>")
}Use the createPreferencesDatastore factory function to create a PreferencesDatastore:
val datastore: PreferencesDatastore = createPreferencesDatastore(
producePath = { context.filesDir.resolve("settings.preferences_pb").absolutePath },
)A fileName overload is available that appends the file name to a directory path:
val datastore = createPreferencesDatastore(
fileName = "settings.preferences_pb",
producePath = { context.filesDir.absolutePath },
)Optional parameters allow customizing the corruption handler, migrations, coroutine scope, and the
default Json instance used for serialization-based preferences:
val datastore = createPreferencesDatastore(
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
migrations = listOf(myMigration),
scope = CoroutineScope(SupervisorJob() + IO),
defaultJson = Json { prettyPrint = true },
producePath = { context.filesDir.resolve("settings.preferences_pb").absolutePath },
)createPreferencesDatastore creates a DataStore scope owned by the returned datastore wrapper. Call
datastore.close() when the datastore is no longer needed; if you pass scope, it is used as a
parent lifecycle and is not cancelled by close().
Overloads accepting okio.Path and kotlinx.io.files.Path are also available:
val datastore = createPreferencesDatastore(
produceOkioPath = { "/data/settings.preferences_pb".toPath() },
)
val datastore = createPreferencesDatastore(
produceKotlinxIoPath = { Path("/data/settings.preferences_pb") },
)Alternatively, wrap an existing DataStore<Preferences> directly. This bypasses the factory
lifecycle wiring and requires opting in to InternalGenericDatastoreApi:
@OptIn(InternalGenericDatastoreApi::class)
val datastore = GenericPreferencesDatastore(myExistingDataStore)GenericPreferenceDatastore is a deprecated compatibility typealias for
GenericPreferencesDatastore. Prefer PreferencesDatastore for public API boundaries and
GenericPreferencesDatastore only when directly wiring an existing DataStore<Preferences>.
The PreferencesDatastore provides factory methods for all supported types:
val userName: Preference<String> = datastore.string("user_name", "Guest")
val userScore: Preference<Int> = datastore.int("user_score", 0)
val highScore: Preference<Long> = datastore.long("high_score", 0L)
val volume: Preference<Float> = datastore.float("volume", 1.0f)
val precision: Preference<Double> = datastore.double("precision", 0.0)
val darkMode: Preference<Boolean> = datastore.bool("dark_mode", false)
val tags: Preference<Set<String>> = datastore.stringSet("tags")Current Preferences API surface:
| Category | APIs |
|---|---|
| Lifecycle | close() |
| Primitives | string, long, int, float, double, bool, stringSet |
| Nullable primitives | nullableString, nullableStringSet, nullableInt, nullableLong, nullableFloat, nullableDouble, nullableBool |
| Custom values | serialized, serializedSet, serializedList, nullableSerialized, nullableSerializedList |
| Kotlin Serialization | kserialized, kserializedSet, kserializedList, nullableKserialized, nullableKserializedList |
| Enum helpers | enum, enumSet, nullableEnum |
| Reads and writes | get, set, update, delete, resetToDefault, asFlow, stateIn, stateInCurrent, blocking variants, property delegation |
| Batch operations | batchReadFlow, batchRead, batchWrite, batchUpdate, batchReadBlocking, batchWriteBlocking, batchUpdateBlocking |
| Backup and restore | exportAsData, exportAsString, importData, importDataAsString, clearAll |
| Utilities | map, mapIO, toggle, toJsonElement, toJsonMap, BasePreference.privateKey, BasePreference.appStateKey |
| Deprecated compatibility | GenericPreferenceDatastore, export, import |
Store enum values directly using the enum() extension:
enum class Theme { LIGHT, DARK, SYSTEM }
val themePref: Preference<Theme> = datastore.enum("theme", Theme.SYSTEM)Store any object by providing serializer/deserializer functions:
@Serializable
data class UserProfile(val id: Int, val email: String)
val userProfilePref: Preference<UserProfile> = datastore.serialized(
key = "user_profile",
defaultValue = UserProfile(0, ""),
serializer = { Json.encodeToString(UserProfile.serializer(), it) },
deserializer = { Json.decodeFromString(UserProfile.serializer(), it) },
)Or with a sealed class:
sealed class Animal(val name: String) {
data object Dog : Animal("Dog")
data object Cat : Animal("Cat")
companion object {
fun from(value: String): Animal = when (value) {
"Dog" -> Dog
"Cat" -> Cat
else -> throw Exception("Unknown animal type: $value")
}
fun to(animal: Animal): String = animal.name
}
}
val animalPref = datastore.serialized(
key = "animal",
defaultValue = Animal.Dog,
serializer = { Animal.to(it) },
deserializer = { Animal.from(it) },
)Store any @Serializable type directly without manual serializer/deserializer functions:
@Serializable
data class UserProfile(val name: String, val age: Int)
val userProfilePref: Preference<UserProfile> = datastore.kserialized(
key = "user_profile",
defaultValue = UserProfile(name = "John", age = 25),
)A custom Json instance can be provided if needed:
val customJson = Json { prettyPrint = true }
val userProfilePref: Preference<UserProfile> = datastore.kserialized(
key = "user_profile",
defaultValue = UserProfile(name = "John", age = 25),
json = customJson,
)Store a Set of custom objects using per-element serialization with serializedSet():
val animalSetPref: Preference<Set<Animal>> = datastore.serializedSet(
key = "animal_set",
defaultValue = emptySet(),
serializer = { Animal.to(it) },
deserializer = { Animal.from(it) },
)Each element is individually serialized to a String and stored using a stringSetPreferencesKey.
Elements that fail deserialization are silently skipped.
Store a Set of @Serializable objects without manual serializer/deserializer functions:
@Serializable
data class UserProfile(val name: String, val age: Int)
val profileSetPref: Preference<Set<UserProfile>> = datastore.kserializedSet(
key = "profile_set",
defaultValue = emptySet(),
)Each element is individually serialized to JSON and stored using a stringSetPreferencesKey.
Elements that fail deserialization are silently skipped. A custom Json instance can be provided if
needed.
Store a List of custom objects using per-element serialization with serializedList():
val animalListPref: Preference<List<Animal>> = datastore.serializedList(
key = "animal_list",
defaultValue = emptyList(),
serializer = { Animal.to(it) },
deserializer = { Animal.from(it) },
)Each element is individually serialized to a String and stored as a JSON array string using a
stringPreferencesKey. Elements that fail deserialization are silently skipped. Unlike sets, lists
preserve insertion order and allow duplicates.
Store a List of @Serializable objects without manual serializer/deserializer functions:
@Serializable
data class UserProfile(val name: String, val age: Int)
val profileListPref: Preference<List<UserProfile>> = datastore.kserializedList(
key = "profile_list",
defaultValue = emptyList(),
)The list is serialized to a JSON array string and stored using a stringPreferencesKey. If
deserialization fails (e.g., due to corrupted data), the default value is returned. A custom Json
instance can be provided if needed.
Store a Set of enum values with the enumSet() extension:
val themeSetPref: Preference<Set<Theme>> = datastore.enumSet<Theme>(
key = "theme_set",
defaultValue = emptySet(),
)Each enum value is stored by its name. Unknown enum values encountered during deserialization are
skipped.
Create preferences that return null when no value has been set, instead of a default value:
val nickname: Preference<String?> = datastore.nullableString("nickname")
val age: Preference<Int?> = datastore.nullableInt("age")
val timestamp: Preference<Long?> = datastore.nullableLong("timestamp")
val weight: Preference<Float?> = datastore.nullableFloat("weight")
val latitude: Preference<Double?> = datastore.nullableDouble("latitude")
val agreed: Preference<Boolean?> = datastore.nullableBool("agreed")
val labels: Preference<Set<String>?> = datastore.nullableStringSet("labels")Setting a nullable preference to null removes the key from DataStore. resetToDefault() also
removes the key, since the default is null.
nickname.get() // null (not set yet)
nickname.set("Alice") // stores "Alice"
nickname.get() // "Alice"
nickname.set(null) // removes the key
nickname.get() // nullStore an enum value that returns null when not set:
val themePref: Preference<Theme?> = datastore.nullableEnum<Theme>("theme")Store a nullable custom-serialized object:
val animalPref: Preference<Animal?> = datastore.nullableSerialized(
key = "animal",
serializer = { Animal.to(it) },
deserializer = { Animal.from(it) },
)Store a nullable @Serializable type:
@Serializable
data class UserProfile(val name: String, val age: Int)
val userProfilePref: Preference<UserProfile?> =
datastore.nullableKserialized<UserProfile>("user_profile")Store a nullable list of custom-serialized objects:
val animalListPref: Preference<List<Animal>?> = datastore.nullableSerializedList(
key = "animal_list",
serializer = { Animal.to(it) },
deserializer = { Animal.from(it) },
)Store a nullable list of @Serializable objects:
val profileListPref: Preference<List<UserProfile>?> =
datastore.nullableKserializedList<UserProfile>(
key = "profile_list",
)All nullable variants return null when the key is not set. Setting null removes the key. If
deserialization fails, null is returned.
Serializer-backed preferences are intentionally lenient. A non-null serialized preference returns
its defaultValue when the stored payload cannot be decoded. Nullable serialized preferences
return null. Set and list variants skip individual elements that fail to decode when the outer
collection can still be parsed.
This keeps reads resilient after app downgrades, partial migrations, or user-edited backup data. If decode failures must be surfaced, validate the serialized payload in your serializer or migration code before exposing it as a preference value.
Each Preference<T> provides multiple access patterns:
CoroutineScope(SupervisorJob() + IO).launch {
val name = userName.get()
userName.set("John Doe")
userName.delete()
}userName.asFlow().collect { value -> /* react to changes */ }
val nameState: StateFlow<String> = userName.stateIn(viewModelScope)
// Suspends once to initialize StateFlow.value with the persisted value.
val currentNameState: StateFlow<String> = userName.stateInCurrent(viewModelScope)// Blocks the calling thread — avoid on the main/UI thread
val name = userName.getBlocking()
userName.setBlocking("John Doe")DelegatedPreference<T> implements ReadWriteProperty, so you can use it as a delegated property:
var currentUserName: String by userName
// Read (blocking)
println(currentUserName)
// Write (blocking)
currentUserName = "Jane Doe"Atomically read-modify-write a preference value in a single DataStore transaction:
userScore.update { current -> current + 1 }Flip a Boolean preference:
darkMode.toggle()Toggle an item in a Set preference (adds if absent, removes if present):
tags.toggle("kotlin")// Suspend
userName.resetToDefault()
// Blocking — avoid on the main/UI thread
userName.resetToDefaultBlocking()Retrieve the underlying DataStore key name:
val key: String = userName.key()Batch operations let you read multiple preferences from one DataStore snapshot or write multiple preferences in one DataStore transaction, avoiding redundant I/O and keeping related values consistent.
Read multiple preferences from a single snapshot:
class SettingsViewModel(
private val datastore: PreferencesDatastore,
) : ViewModel() {
private val userName = datastore.string("user_name", "Guest")
private val darkMode = datastore.bool("dark_mode", false)
private val volume = datastore.float("volume", 1.0f)
fun loadSettings() {
viewModelScope.launch {
val (name, isDark, vol) = datastore.batchRead {
Triple(get(userName), get(darkMode), get(volume))
}
}
}
}You can use either get(pref) or the index operator this[pref]. For reusable read models,
define an extension on BatchReadScope and call it from batchRead or batchReadFlow:
data class SettingsSnapshot(
val userName: String,
val darkMode: Boolean,
val volume: Float,
)
class SettingsRepository(
private val datastore: PreferencesDatastore,
) {
private val userName = datastore.string("user_name", "Guest")
private val darkMode = datastore.bool("dark_mode", false)
private val volume = datastore.float("volume", 1.0f)
private fun BatchReadScope.settingsSnapshot() = SettingsSnapshot(
userName = this[userName],
darkMode = this[darkMode],
volume = this[volume],
)
suspend fun loadSettings() = datastore.batchRead { settingsSnapshot() }
}The batch scopes are marked with a Kotlin DSL marker, so if you nest batch operations inside other DSLs, qualify the receiver explicitly when needed.
Observe multiple preferences reactively from the same snapshot. The flow re-emits whenever any
preference in the datastore changes. Pass distinctUntilChanged = true when the derived value
should only emit after it changes according to equals:
class SettingsViewModel(
private val datastore: PreferencesDatastore,
) : ViewModel() {
private val userName = datastore.string("user_name", "Guest")
private val darkMode = datastore.bool("dark_mode", false)
val settingsFlow: Flow<Pair<String, Boolean>> = datastore
.batchReadFlow(distinctUntilChanged = true) {
get(userName) to get(darkMode)
}
}Write multiple preferences in a single atomic transaction:
class SettingsViewModel(
private val datastore: PreferencesDatastore,
) : ViewModel() {
private val userName = datastore.string("user_name", "Guest")
private val darkMode = datastore.bool("dark_mode", false)
private val volume = datastore.float("volume", 1.0f)
fun resetSettings() {
viewModelScope.launch {
datastore.batchWrite {
set(userName, "Guest")
set(darkMode, false)
set(volume, 1.0f)
}
}
}
}Atomically read current values and write new values in a single transaction, guaranteeing consistency when new values depend on current ones:
class GameViewModel(
private val datastore: PreferencesDatastore,
) : ViewModel() {
private val userScore = datastore.int("user_score", 0)
private val highScore = datastore.long("high_score", 0L)
fun submitScore(newScore: Int) {
viewModelScope.launch {
datastore.batchUpdate {
set(userScore, newScore)
val currentHigh = get(highScore)
if (newScore > currentHigh) {
set(highScore, newScore.toLong())
}
}
}
}
}The BatchUpdateScope also provides update, delete, and resetToDefault helpers:
datastore.batchUpdate {
update(userScore) { current -> current + 10 }
delete(nickname)
resetToDefault(volume)
}Blocking variants are available for non-coroutine contexts. Avoid calling these on the main/UI thread:
val (name, isDark) = datastore.batchReadBlocking {
get(userName) to get(darkMode)
}
datastore.batchWriteBlocking {
set(userName, "Guest")
set(darkMode, false)
}
datastore.batchUpdateBlocking {
update(userScore) { current -> current + 1 }
}Transform a Preference<T> into a Preference<R> with converter functions:
val scoreAsString: Preference<String> = userScore.map(
defaultValue = "0",
convert = { it.toString() },
reverse = { it.toIntOrNull() ?: 0 },
)
// Or infer the default value from the original preference's default:
val scoreAsString2: Preference<String> = userScore.mapIO(
convert = { it.toString() },
reverse = { it.toInt() },
)map catches exceptions in conversions and falls back to defaults. mapIO throws if conversion of
the default value fails.
PreferencesDatastore supports exporting and importing all preferences using type-safe backup
models:
val backup: PreferencesBackup = datastore.exportAsData(
exportPrivate = false,
exportAppState = false,
)val jsonString: String = datastore.exportAsString(
exportPrivate = false,
exportAppState = false,
)A custom Json instance can be provided if needed:
val jsonString: String = datastore.exportAsString(
exportPrivate = false,
exportAppState = false,
json = customJson,
)datastore.importData(
backup = backup,
importPrivate = false,
importAppState = false,
)datastore.importDataAsString(
backupString = jsonString,
importPrivate = false,
importAppState = false,
)Import merges into existing preferences. Call datastore.clearAll() before import for full replace
semantics. A BackupParsingException is thrown if the JSON string is invalid or exceeds the 10 MB
size limit.
The older export() and import() APIs remain available for source compatibility, but are
deprecated. Prefer exportAsData / exportAsString and importData / importDataAsString.
Use BasePreference.privateKey(key) or BasePreference.appStateKey(key) to prefix keys so they can
be filtered during backup:
val token = datastore.string(BasePreference.privateKey("auth_token"), "")
val onboarded = datastore.bool(BasePreference.appStateKey("onboarding_done"), false)Use the createProtoDatastore factory function to create a ProtoDatastore:
val protoDatastore: ProtoDatastore<MyProtoMessage> = createProtoDatastore(
serializer = MyProtoSerializer,
defaultValue = MyProtoMessage.getDefaultInstance(),
producePath = { context.filesDir.resolve("my_proto.pb").absolutePath },
)A fileName overload is available that appends the file name to a directory path:
val protoDatastore = createProtoDatastore(
serializer = MyProtoSerializer,
defaultValue = MyProtoMessage.getDefaultInstance(),
fileName = "my_proto.pb",
producePath = { context.filesDir.absolutePath },
)Optional parameters allow customizing the key, corruption handler, migrations, coroutine scope, and
the default Json instance used for serialization-based field preferences:
val protoDatastore = createProtoDatastore(
serializer = MyProtoSerializer,
defaultValue = MyProtoMessage.getDefaultInstance(),
key = "my_proto",
corruptionHandler = ReplaceFileCorruptionHandler { MyProtoMessage.getDefaultInstance() },
migrations = listOf(myMigration),
scope = CoroutineScope(SupervisorJob() + IO),
defaultJson = Json { prettyPrint = true },
producePath = { context.filesDir.resolve("my_proto.pb").absolutePath },
)createProtoDatastore creates a DataStore scope owned by the returned datastore wrapper. Call
protoDatastore.close() when the datastore is no longer needed; if you pass scope, it is used as
a parent lifecycle and is not cancelled by close().
Overloads accepting okio.Path and kotlinx.io.files.Path are also available:
val protoDatastore = createProtoDatastore(
serializer = MyProtoSerializer,
defaultValue = MyProtoMessage.getDefaultInstance(),
produceOkioPath = { "/data/my_proto.pb".toPath() },
)
val protoDatastore = createProtoDatastore(
serializer = MyProtoSerializer,
defaultValue = MyProtoMessage.getDefaultInstance(),
produceKotlinxIoPath = { Path("/data/my_proto.pb") },
)Alternatively, wrap an existing DataStore<T> directly. This bypasses the factory lifecycle
wiring and requires opting in to InternalGenericDatastoreApi:
@OptIn(InternalGenericDatastoreApi::class)
val protoDatastore = GenericProtoDatastore(
datastore = myExistingProtoDataStore,
defaultValue = MyProtoMessage.getDefaultInstance(),
)Current Proto API surface:
| Category | APIs |
|---|---|
| Lifecycle | close() |
| Whole message | data() |
| Raw fields | field() |
| Enum fields | enumField, nullableEnumField, enumSetField |
| Caller-serialized fields | serializedField, nullableSerializedField, serializedListField, nullableSerializedListField, serializedSetField |
| Kotlin Serialization fields | kserializedField, nullableKserializedField, kserializedListField, nullableKserializedListField, kserializedSetField |
| Preference operations | get, set, update, delete, resetToDefault, asFlow, stateIn, stateInCurrent, blocking variants, property delegation |
val dataPref: ProtoPreference<MyProtoMessage> = protoDatastore.data()
// Then use get(), set(), asFlow(), etc. just like Preferences DataStoreUse field() to create a preference for an individual field of the proto message. The getter
extracts the field value from a snapshot, and updater returns a new proto with the field updated:
val namePref: ProtoPreference<String> = protoDatastore.field(
defaultValue = "",
getter = { it.name },
updater = { proto, value -> proto.copy(name = value) },
)
// Suspend
namePref.set("Alice")
val name = namePref.get() // "Alice"
// Flow
namePref.asFlow().collect { value -> /* react to changes */ }
// Blocking
namePref.setBlocking("Bob")
val blocking = namePref.getBlocking() // "Bob"
// Delegation
var userName: String by namePrefFor nested fields, chain copy() calls in the updater:
data class Address(val street: String = "", val city: String = "")
data class Profile(val nickname: String = "", val address: Address = Address())
data class Settings(val id: Int = 0, val profile: Profile = Profile())
// Access a deeply nested field
val cityPref: ProtoPreference<String> = protoDatastore.field(
defaultValue = "",
getter = { it.profile.address.city },
updater = { proto, value ->
proto.copy(
profile = proto.profile.copy(
address = proto.profile.address.copy(city = value),
),
)
},
)For nullable nested fields, provide fallback defaults in the updater:
data class NullableProfile(val nickname: String = "", val age: Int? = null)
data class NullableSettings(val id: Int = 0, val profile: NullableProfile? = null)
val agePref: ProtoPreference<Int?> = protoDatastore.field(
defaultValue = null,
getter = { it.profile?.age },
updater = { proto, value ->
proto.copy(
profile = (proto.profile ?: NullableProfile()).copy(age = value),
)
},
)Field preferences share the same underlying DataStore, so changes through field() are visible
via data() and vice versa. delete() and resetToDefault() on a field reset only the targeted
field to its default value (via set(defaultValue) → updater(current, fieldDefault)). It does
not reset the entire proto to its default.
Store an enum value in a proto String field using enumField():
enum class Theme { LIGHT, DARK, SYSTEM }
val themePref: ProtoPreference<Theme> = protoDatastore.enumField(
defaultValue = Theme.SYSTEM,
getter = { it.theme },
updater = { proto, value -> proto.copy(theme = value) },
)The reified overload infers enumValues automatically. Unknown enum names encountered during
deserialization fall back to the default value.
Store a nullable enum field using nullableEnumField():
val themePref: ProtoPreference<Theme?> = protoDatastore.nullableEnumField<Settings, Theme>(
getter = { it.theme },
updater = { proto, value -> proto.copy(theme = value) },
)Returns null when the proto field is null. Unknown enum names also return null.
Store a Set of enum values backed by a Set<String> proto field:
val themesPref: ProtoPreference<Set<Theme>> = protoDatastore.enumSetField<Settings, Theme>(
defaultValue = emptySet(),
getter = { it.themes },
updater = { proto, value -> proto.copy(themes = value) },
)Each enum value is stored by its name. Unknown enum names are skipped during deserialization.
Store a custom-serialized object in a proto String field:
@Serializable
data class UserProfile(val id: Int, val email: String)
val profilePref: ProtoPreference<UserProfile> = protoDatastore.serializedField(
defaultValue = UserProfile(0, ""),
serializer = { Json.encodeToString(UserProfile.serializer(), it) },
deserializer = { Json.decodeFromString(UserProfile.serializer(), it) },
getter = { it.profileJson },
updater = { proto, value -> proto.copy(profileJson = value) },
)If the raw string is blank or deserialization fails, the default value is returned.
Store a nullable custom-serialized object:
val profilePref: ProtoPreference<UserProfile?> = protoDatastore.nullableSerializedField(
serializer = { Json.encodeToString(UserProfile.serializer(), it) },
deserializer = { Json.decodeFromString(UserProfile.serializer(), it) },
getter = { it.profileJson },
updater = { proto, value -> proto.copy(profileJson = value) },
)Returns null when the proto field is null. Setting null writes null to the proto field.
Store any @Serializable type in a proto String field using Kotlin Serialization directly:
val profilePref: ProtoPreference<UserProfile> = protoDatastore.kserializedField(
defaultValue = UserProfile(0, ""),
getter = { it.profileJson },
updater = { proto, value -> proto.copy(profileJson = value) },
)The reified overload infers KSerializer automatically. A custom Json instance can be provided
if needed.
Store a nullable @Serializable type:
val profilePref: ProtoPreference<UserProfile?> =
protoDatastore.nullableKserializedField<Settings, UserProfile>(
getter = { it.profileJson },
updater = { proto, value -> proto.copy(profileJson = value) },
)Store a List of custom objects using per-element serialization in a proto String field:
val animalListPref: ProtoPreference<List<Animal>> = protoDatastore.serializedListField(
defaultValue = emptyList(),
elementSerializer = { Animal.to(it) },
elementDeserializer = { Animal.from(it) },
getter = { it.animalsJson },
updater = { proto, value -> proto.copy(animalsJson = value) },
)Each element is individually serialized and stored as a JSON array string.
Store a nullable list of custom-serialized objects:
val animalListPref: ProtoPreference<List<Animal>?> = protoDatastore.nullableSerializedListField(
elementSerializer = { Animal.to(it) },
elementDeserializer = { Animal.from(it) },
getter = { it.animalsJson },
updater = { proto, value -> proto.copy(animalsJson = value) },
)Store a List of @Serializable objects using Kotlin Serialization:
val profileListPref: ProtoPreference<List<UserProfile>> = protoDatastore.kserializedListField(
defaultValue = emptyList(),
getter = { it.profilesJson },
updater = { proto, value -> proto.copy(profilesJson = value) },
)Store a nullable list of @Serializable objects:
val profileListPref: ProtoPreference<List<UserProfile>?> =
protoDatastore.nullableKserializedListField<Settings, UserProfile>(
getter = { it.profilesJson },
updater = { proto, value -> proto.copy(profilesJson = value) },
)Store a Set of custom objects using per-element serialization backed by a Set<String> proto
field:
val animalSetPref: ProtoPreference<Set<Animal>> = protoDatastore.serializedSetField(
defaultValue = emptySet(),
serializer = { Animal.to(it) },
deserializer = { Animal.from(it) },
getter = { it.animalNames },
updater = { proto, value -> proto.copy(animalNames = value) },
)Elements that fail deserialization are silently skipped.
Store a Set of @Serializable objects with per-element JSON serialization backed by a
Set<String> proto field:
val profileSetPref: ProtoPreference<Set<UserProfile>> = protoDatastore.kserializedSetField(
defaultValue = emptySet(),
getter = { it.profileNames },
updater = { proto, value -> proto.copy(profileNames = value) },
)Elements that fail deserialization are silently skipped. A custom Json instance can be provided
if needed.
The remember() extension turns any DelegatedPreference<T> into a lifecycle-aware
MutableState<T>:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
@Composable
fun SettingsScreen(datastore: PreferencesDatastore) {
var userName by datastore.string("user_name", "Guest").remember()
Column {
Text("Current User: $userName")
OutlinedTextField(
value = userName,
onValueChange = { userName = it },
label = { Text("Enter username") },
)
}
}Under the hood, remember() uses collectAsStateWithLifecycle for lifecycle-safe collection for
Android while it uses collectAsState() for Desktop/JVM. It launches a coroutine for writes.
An optimistic local override is applied immediately so that synchronous UI inputs reflect the new
value without waiting for the DataStore round-trip.
Collects a batchReadFlow as a Compose State, reading multiple preferences from a single
DataStore snapshot:
@Composable
fun SettingsScreen(datastore: PreferencesDatastore) {
val userName = datastore.string("user_name", "Guest")
val darkMode = datastore.bool("dark_mode", false)
val settings by datastore.rememberBatchRead {
get(userName) to get(darkMode)
}
settings?.let { (name, isDark) ->
Text("User: $name, Dark mode: $isDark")
}
}The returned State is null until the first snapshot is available.
Remembers multiple preferences (2–5) as individual MutableState values, reading from a shared
batchReadFlow snapshot and writing via batchWrite. All reads share a single DataStore
observation, and writes are launched asynchronously with an optimistic local override:
@Composable
fun SettingsScreen(datastore: PreferencesDatastore) {
val userName = datastore.string("user_name", "Guest")
val darkMode = datastore.bool("dark_mode", false)
val volume = datastore.float("volume", 1.0f)
val (name, isDark, vol) = datastore.rememberPreferences(userName, darkMode, volume)
Column {
var nameValue by name
OutlinedTextField(
value = nameValue,
onValueChange = { nameValue = it },
label = { Text("Username") },
)
var isDarkValue by isDark
Switch(checked = isDarkValue, onCheckedChange = { isDarkValue = it })
}
}Overloads are available for 2, 3, 4, and 5 preferences. The returned PreferencesStateN also
supports property delegation:
val prefs by datastore.rememberPreferences(userName, darkMode)
Text("User: ${prefs.first}, Dark: ${prefs.second}")
prefs.first = "New Name" // triggers an async writeUse ProvidePreferencesDatastore to supply a PreferencesDatastore via CompositionLocal, then
call the standalone rememberPreferences overloads without an explicit datastore receiver:
@Composable
fun App(datastore: PreferencesDatastore) {
ProvidePreferencesDatastore(datastore) {
SettingsScreen()
}
}
@Composable
fun SettingsScreen() {
val userName = LocalPreferencesDatastore.current.string("user_name", "Guest")
val darkMode = LocalPreferencesDatastore.current.bool("dark_mode", false)
val (name, isDark) = rememberPreferences(userName, darkMode)
var nameValue by name
var isDarkValue by isDark
Column {
OutlinedTextField(
value = nameValue,
onValueChange = { nameValue = it },
label = { Text("Username") },
)
Switch(checked = isDarkValue, onCheckedChange = { isDarkValue = it })
}
}Accessing LocalPreferencesDatastore without a provider throws an IllegalStateException.