DataStore for preferences
Priya sells her onions at Lasalgaon. Every morning she opens Mandi Bhav, and every morning the list starts with whatever sorts first, and she scrolls past Pune and Nashik to find her own market. The app should remember that Lasalgaon is hers. That one fact has to outlive more than anything module 5 saved: not only a rotation or Android killing the process, but Priya swiping the app away, the phone restarting, and next month's update. This lesson keeps it in Preferences DataStore, Jetpack's library for small settings on disk, and uses it to put her home market first on the Prices tab. It also shows the two ways DataStore goes wrong, one of which crashes on a screen that has nothing to do with the mistake.
What survives what
Module 5 kept the search and the chosen chip in a SavedStateHandle, and module 7 kept prices in a repository in memory. Neither is enough for Priya's market. On the emulator, with the Onion chip chosen, Nashik set as the home market by this lesson's code, and the 4 pm prices fetched:
| Kept in | Rotation | Process death, back from Recents | Force stop, then open | App update | Clear storage or uninstall |
|---|---|---|---|---|---|
remember |
Lost | Lost | Lost | Lost | Lost |
rememberSaveable, SavedStateHandle (the chip) |
Kept | Kept | Lost | Lost | Lost |
| A repository in memory (module 7's prices) | Kept | Lost: ₹2,450 again | Lost | Lost | Lost |
| DataStore (the home market) | Kept | Kept | Kept | Kept | Lost |
| Room, lesson 2 | Kept | Kept | Kept | Kept, with a migration | Lost |
Saved state is for "put the screen back as it was"; Android throws it away when the user closes the task on purpose. After adb shell am force-stop and a fresh launch, the chip was back on "All" and the list began "Nashik APMC, your home market, comes first." If the user would be annoyed to set it twice, it does not belong in saved state.
Why not SharedPreferences
Android has had SharedPreferences, a key-value file, since version 1, and most apps you open at work use it. It is one line to call, and it hides what that line costs. In a scratch copy, MainActivity.onCreate turns on StrictMode, which reports disk access on the main thread, and reads and writes one value:
// Experiment: report any disk access on the main thread.
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().penaltyLog().build(),
)
val start = System.nanoTime()
val prefs = getSharedPreferences("old_preferences", MODE_PRIVATE)
val home = prefs.getString("home_market", null)
Log.d("Prefs", "read home=$home in ${(System.nanoTime() - start) / 1_000} us on ${Thread.currentThread().name}")
prefs.edit().putString("home_market", "Nashik APMC").commit()
Those four lines produced nine StrictMode violations, five reads and four writes, all on main:
D/Prefs (15713): read home=null in 3455 us on main
D/StrictMode(15713): StrictMode policy violation; ~duration=64 ms: android.os.strictmode.DiskReadViolation
D/StrictMode(15713): at android.app.SharedPreferencesImpl.awaitLoadedLocked(SharedPreferencesImpl.java:283)
D/StrictMode(15713): at android.app.SharedPreferencesImpl.getString(SharedPreferencesImpl.java:309)
D/StrictMode(15713): StrictMode policy violation; ~duration=63 ms: android.os.strictmode.DiskWriteViolation
D/StrictMode(15713): at android.app.SharedPreferencesImpl.writeToFile(SharedPreferencesImpl.java:811)
D/StrictMode(15713): at android.app.SharedPreferencesImpl$EditorImpl.commit(SharedPreferencesImpl.java:612)
getString looks like reading a field. It waits, in awaitLoadedLocked, until the whole XML file has been read from flash, on whatever thread called it. On the emulator that was 3.5 ms; on a three-year-old phone with slow, nearly full storage it is the stutter in your first frame. commit() writes on the calling thread too. apply() returns at once and writes later, but Android waits for pending apply() writes when an activity stops, so the cost moves rather than disappearing, and if the write fails you are never told. Module 9's lesson on the main thread and ANRs uses StrictMode properly; here it is enough to see what one innocent-looking read does.
DataStore was built to remove exactly these:
SharedPreferences |
Preferences DataStore | |
|---|---|---|
| Reading | Synchronous; blocks the caller until the file is loaded | A Flow; the file is read off the main thread |
| Writing | commit() blocks, apply() hides failures |
edit { } is a suspend function that finishes when the write is on disk, or throws |
| Several changes at once | Separate calls; another writer can interleave | One edit { } block is one transaction |
| Watching for changes | A listener you must register and remove | Collect the flow |
| A file it cannot read | Silently empty | An IOException you can handle |
What DataStore is not for: lists of things, anything you would query or that grows (that is Room, next lesson), and secrets (lesson 5). It rewrites the whole file on every edit, which is fine for a dozen settings and wrong for a thousand prices.
Adding it
On 8 November 2026 the current stable release on Google Maven was 1.3.0, the version Google's DataStore page shows. In gradle/libs.versions.toml:
[versions]
datastore = "1.3.0"
[libraries]
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
and in app/build.gradle.kts, implementation(libs.androidx.datastore.preferences).
One DataStore, made once
A DataStore is an object that owns one file. The library's own delegate creates it, in di/PreferencesModule.kt:
/**
* One DataStore for the file `user_preferences`, for the whole process. The
* delegate keeps one instance per file however often it is read.
*/
private val Context.userPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
name = "user_preferences",
)
@Module
@InstallIn(SingletonComponent::class)
object PreferencesModule {
@Provides
@Singleton
fun provideUserPreferencesDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.userPreferencesDataStore
}
The delegate is a top-level property, so there is one per process, and it puts the file at files/datastore/user_preferences.preferences_pb. @ApplicationContext is Hilt's way to ask for the application Context, the one module 5 said may outlive a screen. @Provides is module 7's other kind of binding: Hilt cannot construct a DataStore, so this function does.
A repository for preferences
Screens never touch the DataStore. They ask a repository, which is an interface for the same reason PriceRepository is: tests want a fake. data/UserPreferencesRepository.kt:
/** The user's own choices, kept on the phone until they change them. */
interface UserPreferencesRepository {
/** The market the user sells at, or null until they choose one. */
val homeMarket: Flow<String?>
/** Makes [market] the home market; null clears it. */
suspend fun setHomeMarket(market: String?)
}
/** [UserPreferencesRepository] in a Preferences DataStore file. */
class DataStoreUserPreferencesRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : UserPreferencesRepository {
override val homeMarket: Flow<String?> = dataStore.data
.catch { exception ->
// A file that cannot be read is no reason to take the prices
// screen down: carry on as if nothing was chosen. Anything that
// is not an IOException is a bug, so let it through.
if (exception is IOException) emit(emptyPreferences()) else throw exception
}
.map { preferences -> preferences[HOME_MARKET] }
.distinctUntilChanged()
override suspend fun setHomeMarket(market: String?) {
dataStore.edit { preferences ->
if (market == null) preferences.remove(HOME_MARKET) else preferences[HOME_MARKET] = market
}
}
private companion object {
val HOME_MARKET = stringPreferencesKey("home_market")
}
}
The pieces:
| Piece | What it does |
|---|---|
stringPreferencesKey("home_market") |
A typed key; intPreferencesKey, booleanPreferencesKey and the rest exist for other types |
dataStore.data |
A Flow<Preferences>: the whole file now, and again after every edit |
catch { } |
Turns a read failure into "no preferences" instead of a crash |
edit { } |
Suspends until the change is written; everything inside is one transaction |
distinctUntilChanged() |
Module 7's operator: another key changing does not re-emit the home market |
It is bound in DataModule next to the price repository, @Binds @Singleton abstract fun bindUserPreferencesRepository(repository: DataStoreUserPreferencesRepository): UserPreferencesRepository.
The trap: two DataStores for one file
Google's DataStore page puts this rule first: never create more than one instance of DataStore for a given file in the same process. With Hilt it breaks without a sound, because module 7 showed that an unscoped binding is a new object every time. In a scratch copy, the provider calls the factory instead of the delegate, and both @Singletons are left off:
// The trap: a factory call with no scope, so every request makes a new DataStore.
@Provides
fun provideUserPreferencesDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("user_preferences") },
)
It builds. The Prices tab opens and reads the home market. Then Markets, Nashik APMC:
E/AndroidRuntime(15581): java.lang.IllegalStateException: There are multiple DataStores active for the same file: /data/data/com.riztechacademy.mandibhav.verify/files/datastore/user_preferences.preferences_pb. You should either maintain your DataStore as a singleton or confirm that there is no two DataStore's active on the same file (by confirming that the scope is cancelled).
E/AndroidRuntime(15581): at androidx.datastore.core.FileStorage.createConnection(FileStorage.kt:52)
The mistake is in a module file; the crash is on the second screen that asks, after the first one worked. DataStore cannot share a file between two instances, so the number of instances is a correctness rule, not a tidiness one. The delegate makes it hard to break: with the delegate back and the binding still unscoped, the same steps worked, because every provider call returned the one delegate-owned instance. Keep both, the delegate and @Singleton, so the file that says how the DataStore is made also says how long it lives.
Using it on two screens
The market screen gets a chip. MarketUiState gains isHomeMarket, and MarketViewModel takes the preferences repository:
val uiState: StateFlow<MarketUiState> =
combine(repository.pricesAt(market), preferences.homeMarket) { prices, home ->
MarketUiState(market = market, prices = prices, isHomeMarket = home == market)
}
/** Makes this market the home market, or clears it if it already is. */
fun toggleHomeMarket() {
val isHome = uiState.value.isHomeMarket
viewModelScope.launch {
preferences.setHomeMarket(if (isHome) null else market)
}
}
MarketScreen gains isHomeMarket and onHomeMarketClick and shows, above the prices:
// A chip, not a plain button: TalkBack reads whether it is
// selected, so the choice is heard as well as seen.
FilterChip(
selected = isHomeMarket,
onClick = onHomeMarketClick,
label = { Text("My home market") },
)
On the Prices tab, the home market's prices go first. The rule is a plain function in PriceFilter.kt, with a test:
fun homeMarketFirst(prices: List<CommodityPrice>, homeMarket: String?): List<CommodityPrice> {
if (homeMarket == null) return prices
val (home, others) = prices.partition { it.market == homeMarket }
return home + others
}
PricesViewModel now has six inputs, and module 7 showed that combine stops at five. The answer is the same as last time: two of them belong together. Today's prices and the market to put first are one thing, "what to list":
/** Today's prices and the market to put first: two inputs that belong together. */
private data class PricesAndHome(val prices: List<CommodityPrice>, val homeMarket: String?)
private val pricesAndHome = combine(repository.todaysPrices(), preferences.homeMarket) { prices, home ->
PricesAndHome(prices, home)
}
and the big combine destructures it, { (prices, home), query, commodity, best, status -> … }, with prices = filterPrices(homeMarketFirst(prices, home), query, commodity) and the new homeMarket = home in PricesUiState. The chips still come from commoditiesIn(prices), so choosing a home market moves prices, not chips. The screen adds one line when a home market is set: "Lasalgaon APMC, your home market, comes first."
Testing
DataStore needs no Android, so the real thing runs in a plain JVM test against a file in a temporary folder:
private fun repositoryIn(scope: CoroutineScope) = DataStoreUserPreferencesRepository(
PreferenceDataStoreFactory.create(scope = scope, produceFile = { file }),
)
@Test
fun theChoiceIsInTheFileNotInMemory() = runTest {
// The first DataStore is written to and then shut down, as the app
// is when its process dies. A new one on the same file is what the
// app sees on its next cold start.
val firstLife = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job())
repositoryIn(firstLife).setHomeMarket("Nashik APMC")
firstLife.cancel()
assertEquals("Nashik APMC", repositoryIn(backgroundScope).homeMarket.first())
}
Cancelling the first scope matters: without it, the second DataStore would be the trap above, in a test. The ViewModel tests use FakeUserPreferencesRepository, a MutableStateFlow<String?> behind the interface, and gain two cases: the home market's prices come first and follow the preference when it changes, and the market chip sets and clears it. ./gradlew testDebugUnitTest runs 64.
Moving off SharedPreferences
An app that already has settings in SharedPreferences does not lose them. The delegate takes migrations that run once, before the first read:
private val Context.userPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
name = "user_preferences",
// Copies the keys from the old SharedPreferences file on first read,
// then deletes them from it.
produceMigrations = { context -> listOf(SharedPreferencesMigration(context, "old_preferences")) },
)
On the emulator, with "Nashik APMC" written to old_preferences.xml by the experiment above, the first launch showed Nashik as the home market and shared_prefs/old_preferences.xml was gone.
Check your work
Why the home market is not in SavedStateHandle. Saved state is discarded when the user closes the app on purpose; after a force stop the chip was back on "All". A preference must survive that.
What getString on SharedPreferences did on the main thread. Blocked until the file was loaded: StrictMode reported a DiskReadViolation in awaitLoadedLocked.
What DataStore gives instead. A Flow for reading, a suspend edit that is one transaction and reports failure, and no disk work on the caller's thread.
What the catch in homeMarket does, and why only IOException. A file that cannot be read becomes "nothing chosen" rather than a crash; any other exception is a bug and should crash in testing.
Why the trap crashed on the Markets screen, not at launch. Only one DataStore existed until a second screen asked Hilt for one; the second instance threw when it touched the file.
Why the delegate survived an unscoped binding. It is a top-level property: one instance per process, however often the provider is called.
Practice 2. Your table should match the one at the top of the lesson for the chip and the home market. The step people get wrong is the second: reopen from Recents, not from the launcher icon, because process death is the system's decision and Recents is how a user comes back to a task the system has kept. The force stop is the user's decision, and it is the only one of the three that loses the chip.
Practice
- Add the dependency,
PreferencesModule,UserPreferencesRepositoryand its DataStore implementation, the binding, the chip,homeMarketFirstwith its test, and thePricesAndHomegrouping. Run the 64 tests. - On the emulator, choose a chip and a home market, then rotate, kill the process from the background and reopen from Recents, and force-stop and reopen. Fill in your own copy of the "what survives what" table.
- Reproduce the two-DataStores crash with the factory and no
@Singleton. Then put the delegate back but leave the binding unscoped, and confirm it no longer crashes. Explain why. - Break it on purpose: implement
UserPreferencesRepositorywithSharedPreferencesinstead, reading the value in aMutableStateFlowinitialiser. Turn on the StrictMode policy from this lesson and read what it reports on launch. - Add a second preference,
defaultCommodity, that pre-selects a chip when the saved state has none. Decide carefully which wins when both exist after process death, and write the test that pins it. - Harder: the file holds a bad byte. Write a test that puts garbage into
user_preferences.preferences_pbbefore creating the DataStore, see whathomeMarketemits, then read aboutReplaceFileCorruptionHandlerand decide whether Mandi Bhav should use one.
Official documentation
- DataStore — Setup, the three rules including one instance per file, reading, writing, corruption handling and backup.
- Working with Preferences DataStore — Google's codelab, including the move from
SharedPreferences. - Save simple data with SharedPreferences — The older API you will meet in existing code, and what
apply()andcommit()do. - Data and file storage overview — Where preferences sit among files, databases and shared storage.
Next: Room, so the 4 pm prices survive the process too.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship