The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
One way or the other, any modern Android application stores some user or config data locally on the device. In the past, developers relied on the SharedPreferences API to store simple data in key-value pairs.
Where SharedPreferences API fails to shine is in its synchronous API for read and write operations. Since Android frowns at performing non-UI work on the main thread, this is not safe to use.
In this article, you will learn how to use the DataStore API with generic persistent storage. This approach will let us create a storage class where we can specify any data type we wish to save as a key-value pair to the device.
We’ll cover the following topics:
getAll operationinsert operationget operationclearAll operationmodel class and in-memory data sourceIn this demo, we will create a sample application to fetch the application’s configurations from an in-memory source and save them on the device using DataStore.
There are a few prerequisites before we can get started:
Let’s start by creating an empty Android Studio project.



Copy and paste the following dependencies into your app-level build.gradle file.
implementation "androidx.datastore:datastore-preferences:1.0.0" implementation "io.insert-koin:koin-android:3.1.4" implementation 'com.google.code.gson:gson:2.8.7'
Alongside the dependency for DataStore are the extra koin and gson dependencies, which are for dependency injection and serialization/deserialization, respectively.
After inserting these dependencies, Android Studio will prompt you to sync the project. This typically takes a few seconds.
Create a Kotlin interface file, like so.
interface Storage<T> {
fun insert(data: T): Flow<Int>
fun insert(data: List<T>): Flow<Int>
fun get(where: (T) -> Boolean): Flow<T>
fun getAll(): Flow<List<T>>
fun clearAll(): Flow<Int
}
We use a storage interface to define the actions for the persistent data storage. In other words, it is a contract the persistent storage will fulfill. Any data type we intend to associate with the interface should be able to carry out all four operations in the interface we created.
PersistentStorage is the concrete implementation of Storage interface we defined in the previous step.
class PersistentStorage<T> constructor(
private val gson: Gson,
private val type: Type,
private val dataStore: DataStore<Preferences>,
private val preferenceKey: Preferences.Key<String>
) : Storage<T>
You will observe by now that we are taking advantage of generics in Storage and PersistentStorage. This is done to achieve type safety. If your code is relying on generic persistent storage to store data, only one data type will be associated with a particular instance of Storage.
There are also a number of object dependencies required:
gson: As previously mentioned, this will be used for serialization/deserializationtype: Our implementation gives the user the flexibility to save more than one piece of data of the same type — and with great power comes great responsibility. Writing and reading a list with GSON will result in corrupted or lost data because Java doesn’t yet provide a way to represent generic types, and GSON cannot recognize which type to use for its conversion at runtime, so we use a type token to effectively convert our objects to a JSON string and vice versa without any complicationsDataStoreDataStore: This will provide APIs for writing to and reading from the preferencesgetAll operation...
fun getAll(): Flow<List> {
return dataStore.data.map { preferences ->
val jsonString = preferences[preferenceKey] ?: EMPTY_JSON_STRING
val elements = gson.fromJson<List>(jsonString, typeToken)
elements
}
}
...
DataStore.data returns a flow of preferences with Flow<Preferences>, which can be transformed into a Flow<List<T>> using the map operator. Inside of the map block, we first attempt to retrieve the JSON string with the preference key.
In the event that the value is null, we assign EMPTY_JSON_STRING to jsonString. EMPTY_JSON_STRING is actually a constant variable, defined like so:
private const val EMPTY_JSON_STRING = "[]"
GSON will conveniently recognize this as a valid JSON string, which represents an empty list of the specified type. This approach is more logical, rather than throwing some exception that could potentially cause a crash in the app. I am sure we don’t want that happening in our apps 🙂
insert operationfun insert(data: List<T>): Flow<Int> {
return flow {
val cachedDataClone = getAll().first().toMutableList()
cachedDataClone.addAll(data)
dataStore.edit {
val jsonString = gson.toJson(cachedDataClone, type)
it[preferenceKey] = jsonString
emit(OPERATION_SUCCESS)
}
}
}
To write data to DataStore, we call edit on Datastore. Within the transform block, we edit the MutablePreferences, as shown in the code block above.
To avoid overwriting the old data with the new, we create a list that contains both old data and new data before modifying MutablePreferences with the newly created list.
n.b., I opted to use method overloading to insert a single or a list of data over a vararg parameter because varargs in Kotlin require extra memory when copying the list of data to an array.
get operationfun get(where: (T) -> Boolean): Flow {
return getAll().map { cachedData ->
cachedData.first(where)
}
}
In this operation, we want to get a single piece of data from the store that matches the predicate where. This predicate is to be implemented on the client side.
clearAll operationfun clearAll(): Flow<Int> {
return flow {
dataStore.edit {
it.remove(preferenceKey)
emit(OPERATION_SUCCESS)
}
}
}
As the name implies, we want to wipe the data that is associated with the preference key. emit(OPERATION_SUCCESS) is our way of notifying the client of a successful operation.
At this point, we have done justice to the generic storage APIs. Up next, we’ll set up the model class and an in-memory data source.
model class and in-memory data sourceCreate a Config data class, like so:
data class Config(val type: String, val enable: Boolean)
To keep things simple, this data class only captures a config type and its corresponding toggle value. Depending on your use case, your config class can describe many more actions.
class DataSource {
private val _configs = listOf(
Config("in_app_billing", true),
Config("log_in_required", false),
)
fun getConfigs(): Flow<List<Config>> {
return flow {
delay(500) // mock network delay
emit(_configs)
}
}
}
For lack of an actual server to connect to, we have our configs stored in-memory and retrieved when needed. We have also included a delay to mock an actual network call.
While this article is focused on creating a minimalistic demo Android app, it is okay to adopt some modern practices. We will implement the code for fetching configs via a ViewModel and provide dependencies to objects where necessary using koin.
Koin is a powerful Kotlin dependency injection framework. It has simple APIs and is relatively easy to set up.
ViewModel classclass MainViewModel(
private val dataSource: DataSource,
private val configStorage: Storage<Config>
) : ViewModel() {
init {
loadConfigs()
}
private fun loadConfigs() = viewModelScope.launch {
dataSource
.getConfigs()
.flatMapConcat(configStorage::insert)
.collect()
}
}
Here, we fetch the configs from a data source and save them to our DataStore preferences.
The intention is to be able to retrieve those configs locally without having to make additional network calls to the server. The most obvious place to initiate this request would be at app launch.
Define your koin modules like so:
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "com.enyason.androiddatastoreexample.shared.preferences")
val dataAccessModule = module {
single<Storage<Config>> {
PersistentStorage(
gson = get(),
type = object : TypeToken<List<Config>>() {}.type,
preferenceKey = stringPreferencesKey("config"),
dataStore = androidContext().dataStore
)
}
single { Gson() }
viewModel {
MainViewModel(
dataSource = DataSource(),
configStorage = get()
)
}
}
We have now delegated the heavy lifting to Koin. We no longer have to worry how the objects are being created — Koin handles all of that for us.
The single definition tells Koin to create only one instance of the specified type throughout the lifecycle of the application. The viewModel definition tells Koin to create only an object type that extends the Android ViewModel class.
We need to initialize Koin to prepare our dependencies before our app requests them. Create an Application class, like so:
class App : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@App)
modules(dataAccessModule)
}
}
}
We have finally wired up all the pieces together, and our project should now work as expected. Kindly check out this GitHub repo for the complete project setup.
Storage<Config> is sure to retrieve only the data of Config typePreferenceHelper classes, which are intended to manage app preferences, usually result in monolith classes, which is a bad software engineering practice. With the generic approach discussed in this article, you can achieve more with less codePersistentStorage<T>Implementing generic persistent storage is an elegant way of managing data with Android DataStore. The gains as I have discussed above outweigh the traditional approach on Android with SharedPreference. I hope you liked this tutorial 😊
LogRocket is an Android monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your Android apps.
LogRocket's Galileo AI watches sessions for you, instantly identifying and explaining user struggles with automated monitoring of your entire product experience.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your Android apps — try LogRocket for free.
Install LogRocket via npm or script tag. LogRocket.init() must be called client-side, not
server-side
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
// Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>

A senior developer discusses how developer elitism breeds contempt and over-reliance on AI, and how you can avoid it in your own workplace.

Examine AgentKit, Open AI’s new tool for building agents. Conduct a side-by-side comparison with n8n by building AI agents with each tool.

AI agents powered by MCP are redefining interfaces, shifting from clicks to intelligent, context-aware conversations.

Learn how platform engineering helps frontend teams streamline workflows with Backstage, automating builds, documentation, and project management.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now