DataStore / SharedPreferences 2026 - Part 2 of 2

DataStore / SharedPreferences (Part 2 of 2)

Introduction

In Part 1, we covered the basic implementation of a textbook DataStore and how we should no longer be using SharedPreferences. If you haven't had the chance to read that yet, I do recommend taking a quick look. It is a quick read for experienced Compose users.

In this, we will focus on improving our code to make it easier to manage, more pleasurable to work with, and more scalable (using DRY).

As this is aimed toward more experienced programmers, we can also add a bit more detail about some of my solutions. This will also be quite dense.

Again, I'm using objects to wrap things. Read this if you want to know why I wrap examples in objects.

Note: For this blog, I will not group ViewModel actions together into a sealed interface (Action Sealed Class / UI Event Sealed Class) as I don't want to introduce new concepts to people who are not already familiar with them. If you are familiar with this pattern, I would recommend implementing that yourself.

Original Code Smells

The stock examples didn't look right to me. They work. They are correct. But, they are ugly

There was no way I intended on having such a lot of code to do something so simple if I could avoid it.

Code Smell Image

The Repository

We can start looking at these sections of the repo:
val counterFlow: Flow<Int> =
context.dataStore.data.map { preferences ->
preferences[EXAMPLE_COUNTER] ?: 0
}

suspend fun updateCounter(value: Int) {
context.dataStore.edit { preferences ->
preferences[EXAMPLE_COUNTER] = value
}
}
We have the getters and setters, but that's quite a lot of code there. If I have a bunch of values to track in my preferences, this is gonna be a lot of copy paste and repeated text. It's not terrible, but I don't want to work with it.

The ViewModel

Was this any better than our repo? 
val counterState: StateFlow<Int> = repo.counterFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted
.WhileSubscribed(5000),
initialValue = 0
)


fun incrementCounter() {
viewModelScope.launch {
repo.updateCounter(counterState.value + 1)
}
}
No. Again, a lot of code that would need to be repeated. The increment is not too bad, but the counterState line is horrible.

No way I want to copy/paste that each time I add a value.

A Slightly More Complex Example

To really have this exercise make sense, we need a little more information in our preferences.

As well as our counter, I'll also add a simple username edit text box. It looks basically the same as the counter so I won't bother repeating it below.

Repo Companion

Let's start by a simple improvement to our Companion object for our repo:
companion object {
const val TAG = "DStre_RpI"

/** Main preference filename*/
private const val USER_PREFERENCES_NAME =
"imp_datastore_user_pref"

private val Context.dataStore:
DataStore<Preferences> by preferencesDataStore(
name = USER_PREFERENCES_NAME
)

val exampleCounterKd = PrefKeyWithDefault(
key = intPreferencesKey("example_counter"),
default = 0
)
val exampleUsernameKd = PrefKeyWithDefault(
key = stringPreferencesKey("example_username"),
default = "Default User"
)
}
I started out by simply adding a default value constant for each preference hook. This was a good start.

On review, after initially posting this, I also added a support class. It was quite easy to accidently pass the wrong default value when not being careful. 
/** Improved Preference Data Hooks */
data class PrefKeyWithDefault<T>(
val key: Preferences.Key<T>,
val default: T
)
This small PrefKeyWithDefault makes it much easier to group the data. Of course, we could also do this as a simple pair but I prefer named types where possible to make intent clearer.

It also improves access and usage as can be seen later.

Repo - Getters

Let's have a look at my solution for the getters:
val counterFlow = dataStore.flow(exampleCounterKd)
val usernameFlow = dataStore.flow(exampleUsernameKd)
Ok, this looks much nicer! We simply need to do a datastore.flow, the key, and the default value? Like this?
fun <T> DataStore<Preferences>.flow(
key: Preferences.Key<T>,
default: T
): Flow<T> =
data.map { prefs -> prefs[key] ?: default }.distinctUntilChanged()
No, we can use the PrefKeyWithDefault improvement reduces this to one argument (as used in the example above). That's a much nicer system to work with.
private fun <T> DataStore<Preferences>.flow(
prefKeyWithDefault: PrefKeyWithDefault<T>,
): Flow<T> = data.map { prefs ->
prefs[prefKeyWithDefault.key] ?: prefKeyWithDefault.default
}.distinctUntilChanged()
We simply add a small extension function  template and we're good to go. Notice how we have also built in distinctUntilChanged to save our UI from refreshing when it doesn't need to. Fantastic.

Repo - Setters

How does this look?
suspend fun updateCounter(value: Int) =
dataStore.update(exampleCounterKd, value)
suspend fun updateUsername(value: String) =
dataStore.update(exampleUsernameKd, value)
Yup, I can definitely work with this. Again, we're making an extension to our datastore.
private suspend fun <T> DataStore<Preferences>.update(
keyDefault: PrefKeyWithDefault<T>, value: T
) {
edit { prefs ->
prefs[keyDefault.key] = value
}
}

Repo - Getters: Improvement

Solution one is great for single values, but we're likely going to group our updates together for more modern style. Let's look at our second, better, solution:

We can make a simple data class for our UI settings:
data class SettingsState(
val counter: Int = exampleCounterKd.default,
val username: String = exampleUsernameKd.default
)
You may wonder why we have the default constructor values - this streamlines the ViewModel code.

Then all we need to do is combine them:
val settingsState = combine(
counterFlow,
usernameFlow
) { counter, username ->
SettingsState(counter, username)
}
I'm also also very happy with this as this should keep the ViewModel clean.

ViewModel Companion

Moving on to the ViewModel, we're adding a subscribe timeout as a constant.

I've also introduced a new getter that takes in our repo as an argument so we can now automate tests (for those that do!). We can keep our simple get() for general usage so you don't need to think about it.

Note: I did receive feedback worried about my implementation here so I have added logging to the Gist so you can see when things are called. I wanted to omit that for the examples. In this simple app, everything is only called once so this works as expected.
companion object {
const val SUBSCRIBE_TIMEOUT = 5000L
fun factory(repo: DataStoreRepoImproved) =
viewModelFactory {
initializer { DataStoreViewModelImproved(repo) }
}

@Composable
fun get(): DataStoreViewModelImproved {
val context = LocalContext.current
val repo = remember { DataStoreRepoImproved(context) }
return viewModel(factory = factory(repo))
}

/**
* IMPORTANT NOTE:
* Fixed our tradeoff issue by adding a manual injection getter
* I wanted to avoid introducing this concept to newer users
*/
@Composable
fun get(repo: DataStoreRepoImproved): DataStoreViewModelImproved {
return viewModel(factory = factory(repo))
}
}

ViewModel Flow Wrapper

We can make our StateFlow code much shorter. Below are the single value and data class versions. We don't actually use this for the final version but it demonstrates how you would add them.
val counterState = repo.counterFlow
.asState(DataStoreRepoImproved
.exampleCounterKd.default)

val usernameState = repo.usernameFlow
.asState(DataStoreRepoImproved
.exampleUsernameKd.default)
val uiState = repo.settingsState
.asState(
SettingsState(
counter = DataStoreRepoImproved.
exampleCounterKd.default,
username = DataStoreRepoImproved.
exampleUsernameKd.default
)
)
Notice we are grabbing the default values from the repo (uiState is improved later)

Remember, this is double the code we actually need as we're not actually going to use the counterState and userNameState. The uiState version is far more flexible for adding additional information.

To allow for these improvements to work, we use the following:
private fun <T> Flow<T>.asState(
initialValue: T
): StateFlow<T> =
stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(
SUBSCRIBE_TIMEOUT
),
initialValue = initialValue
)
So one should templated Flow extension makes our code much easier to read and write! Notice the built in whileSubscribed timeout.

We can actually make a nice improvement to the uiState thanks to adding default values to SettingState earlier. This is a really nice improvement that makes working with the ViewModel much more comfortable.
val uiState = repo.settingsState
.asState( SettingsState() )

Alternative

If our viewModel only needs to access one property from our repo, this is a superior solution:
val uiState = repo.settingsState
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(
SUBSCRIBE_TIMEOUT
),
initialValue = SettingsState()
)
Here, we are avoiding the template and just adding everything directly.

The reason I have the template is an assumption we are monitoring more than one value from our repo. Even if we are just monitoring one value, using the template does help speed up initial construction. You could revert it to this simpler version if you know you only need to track one thing.

ViewModel Setters

There's nothing really to improve here, but I guess we could switch from a Block Body to a Single-expression Function
fun incrementCounter() = viewModelScope.launch {
repo.updateCounter(uiState.value.counter + 1)
}
fun updateUsername(newName: String) = viewModelScope.launch {
repo.updateUsername(newName)
}
But this actually breaks our Ui in an interesting way; if you do this, our lambas no longer expect () -> Unit but now expect () -> Job. As well as making the intention of the code a little confusing, it just causes a whole bunch of problems.

Let's stick to the previous solution (make sure you reference uiState.value.counter for the combined SettingsState solution or it won't update).
fun incrementCounter() {
viewModelScope.launch {
repo.updateCounter(uiState.value.counter + 1)
}
}
fun updateUsername(newName: String) {
viewModelScope.launch {
repo.updateUsername(newName)
}
}

The Ui

I'll just show the part of the UI where we are interacting with the ViewModel directly. Excuse the function naming (Display) here.
@Composable
private fun Display( viewModel: DataStoreViewModelImproved ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val onUpdated: (String)-> Unit = { newName ->
viewModel.updateUsername(newName)
}
val onIncrement = { viewModel.incrementCounter() }

DataStoreImprovedDisplay(
uiState,
onUpdated,
onIncrement
)
}
This is really clean. We just collect the uiState, make our update Lambdas, and pass them it all to our DataStoreImprovedDisplay. Again, the naming isn't something I like, but it gets the point across.

We are using the SettingsState solution in case that is not clear.

Summary

So over the past two blog posts, we've covered quite a lot.

The first part was a DataStore review / introduction for newer programmers. In part two, we've looked at spotting and reducing repetition in order to make the code easier to work on and less error prone.

Relaxed programmer
The real reason I personally do things like this is to make writing code more enjoyable. I hate repeating myself. I love being able to write something short and powerful.

I plan to reuse this code for my projects and will update this blog post if I have any issues or come up with any improvements.

Updated!

I have added a bonus post on this series that covers a Composable only version.

Links

Blog / Gists

Recommended Resources

Thanks

Thank you to Salil from the PL-Coding Mobile Dev Campus for providing a lot of valuable feedback on both part 1 and part 2 of this.

Comments

Popular Posts