DataStore / SharedPreferences 2026 - Bonus!
DataStore / SharedPreferences - BONUS!
Compose Only - Introduction
I had no intentions of writing a third part to this series (part 1 / part 2)... but then needed to add the ability to quickly save values to a project I am testing.
Why not use my previous versions? Well, the project is a single file of Composable objects so I didn't want to add a ViewModel or any extra complications.
What you might learn from this:
- Beginner to Intermediate programmers are likely to pick up a few interesting points from this related to remember functions, debouncing, and how the initial DataStore loading works.
- Experienced programmers will likely not see much new, but may want to copy / paste this solution to quickly throw into existing projects. You may want to skim read and focus on any points you find interesting. It should be a quick read / refresher.
As usual, I'm using objects to wrap things. Read this if you want to know why I wrap examples in objects.
Simple Overview
This is not very complicated as the code pretty much explains itself. There are a few tricks in there that may be harder for newer learners to understand. The main things I'll try to point out are why I did things the way I did and any tradeoffs.
We are using a simple Int counter that increments/clears with a button press.
Host Framework
@Composable
private fun CounterHost() {
val context = LocalContext.current
val savedCounter = rememberCounterState(context) // Loads from flow
if(savedCounter == DSCOM_COUNTER_LOADING) {
Text("Loading...")
} else {
CounterUi(savedCounter, context)
}
}
As DataStore is not immediate, we cannot trust the first value it passes. It will always start with the default value before loading the last known value. If the user managed to increment the value before the DataStore value loaded, it would lose the previous value.
This is generally a good way to deal with flows, but you could easily just remove that if statement and you'd be unlikely to see any problems with this example.
Next we have our rememberCounterState:
@Composable
fun rememberCounterState(context: Context): Int {
val counterFlow = remember {
context.dataStore.data.map { prefs ->
prefs[intPreferencesKey(DSCOM_COUNTER_KEY)] ?: DSCOM_COUNTER_DEFAULT
}
}
val counterState by counterFlow.collectAsState(initial = DSCOM_COUNTER_LOADING)
return counterState
}
I moved this out to a separate function to make the code easier to process.
Note 1: For this test version, we are not handling I/O Exception. Ideally, we would catch anything it throws and deal with that appropriately. I wanted to keep this simple.
Newer Android Compose programmers should note that this is a Composable function, but starts with a lower-case letter and has a return. The return is backed by a remember and this is good practice for recording complex values in Composables. You may not see this very often as ViewModels are generally far superior and suitable for most situations.
Note 2: For newer programmers who don't yet know about remember, you might want to check these official links:
We have our counter constants next. The two main things to note from this are the two default values and the key. The key is expected, but why two default values?
private const val DSCOM_COUNTER_KEY = "dscom_counter_key"
private const val DSCOM_COUNTER_DEFAULT = 0
private const val DSCOM_COUNTER_LOADING = -1
As I've tried to indicate in the name, we have a loading and default. The loading is set before DataStore manages to load the data it needs. Once DataStore can get the data, it will load the last known value or it will default to 0. Using -1 as our loading flag means we can prevent our UI from taking the first value this function produces.
Next is our CounterUi:
@Composable
private fun CounterUi(savedCounterValue: Int, context: Context) {
val scope = rememberCoroutineScope()
TitledCard("DataStoreBacked") {
DataStoreBacked(savedCounterValue, context, scope)
}
TitledCard("MutableValueBacked") {
MutableValueBacked(savedCounterValue, context, scope)
}
}
As you can see, we actually have two versions of our counter.
- DataStoreBacked is loading and saving data on every change. This is inefficient, but it is always kept up to date.
- MutableValueBacked only saves at specific times.
Our TitledCard is a basic layout to reduce code repetition. You can see this in the Gist.
DataStoreBacked
@Composable
private fun DataStoreBacked(
savedCounterValue: Int,
context: Context,
scope: CoroutineScope
) {
CounterUiButtonRow(
value = savedCounterValue,
onIncrement = { onSave(savedCounterValue + 1, context, scope) },
onClear = { onSave(0, context, scope) }
)
}
We take in the saved value and use CounterUiButtonRow which displays the value and handles the two buttons that use onIncrement and onClear.
MutableValueBacked
@Composable
private fun MutableValueBacked(
savedCounterValue: Int,
context: Context,
scope: CoroutineScope
) {
var counter by remember { mutableIntStateOf(savedCounterValue) }
LaunchedEffect(savedCounterValue) {
counter = savedCounterValue
}
LaunchedEffect(counter) {
delay(MUTABLE_DEBOUNCE_TIMER_MS)
onSave(counter, context, scope)
}
CounterUiButtonRow(
value = counter,
onIncrement = { counter++ },
onClear = { counter = 0 }
)
}
The big difference is our counter is now a mutableIntState. This means to Composable is tracking the value rather than relying on the incoming savedCounterValue.
LaunchedEffect
We use LaunchedEffect to update our counter whenever savedCounterValue changes. If we didn't do this and instead set our initial counter value to the savedCounterValue, it would go out of sync if anything else updated that value. If this is the only place where this value could change, then that would be a reasonable solution.
Debounce
We are debouncing our saves by half a second using the constant debounce value:
private const val MUTABLE_DEBOUNCE_TIMER_MS = 500L
If you haven't come across debounce before, this is a system that delays some kind of update for performance, power, or similar reasons. We've implemented it here so it will only safe once the user stops spamming the button.
The 500ms delay seems like a good match for our purpose. The user is likely to click a bunch of times then stop for a bit. Any delay of over 500ms would allow the save to take place. The delay can, of course, be increased or decreased based on the needs of the test.
You would likely use a ViewModel and ensure that everything is saved before the user exits the app for production. For testing, we could potentially lose the latest changes if the user quits the app in time. I briefly tested if this was possible, but couldn't manage get it to fail. Still, don't rely on this.
onSave
I skimmed over onSave earlier, so let's take a quick look at it now.
fun onSave(
newValue: Int,
context: Context,
scope: CoroutineScope
) {
scope.launch {
context.dataStore.edit { prefs ->
prefs[intPreferencesKey(DSCOM_COUNTER_KEY)] = newValue
}
}
}
Pretty simple. We're launching a coroutine and then setting the dataStore value from the key and the incoming value.
private val Context.dataStore by preferencesDataStore(name = "dscom_state")
We set the dataStore reference earlier in the file.
Comparison
In the test, we have both versions running at the same time so you can visually see what is happening.
1) Try updating the top DataStoreBacked version. You will notice both counters update immediately (or after a very small delay as it saves and loads data).
2) Next, increment the MutableValueBacked version. You will notice the counter updates immediately on this card but the DataStoreBacked card version will not update until you stop clicking for half a second. This is a good visual demonstration of the debounce effect in action.
3) If you increment the second card and then quickly go back to the first, you will notice things go out of sync. This demonstrates the importance of not letting multiple Composables try to save the same thing at the same time, especially when combined with Debounce.
When to Use
- DataStoreBacked if you are using sensitive test data that must be updated immediately.
- MutableValueBacked with Debounce if you expect a lot of rapid updates
Summary
Blog / Gists
- DataStoreComposeOnlyMinimal GitHub Gist
- Blog Part 2
- Part 2 - DataStoreRepoImproved GitHub Gist
- Part 2 - DataStoreUiImproved GitHub Gist
- ComposableWidgets GitHub Gist (for parts 1 and 2)
- Blog Part 1
- Part 1 - DataStoreRepoBasic.kt GitHub Gist
- Part 1 - DataStoreUiBasic GitHub Gist

Comments
Post a Comment