Managing Local Data with DataStore, Kotlinx.Serialization, Paging3, and Compose
When building Modern Android Development (MAD) applications, managing local states like a “Bookmark” or “Favorite” feature can be…
Managing Local Data with DataStore, Kotlinx.Serialization, Paging3, and Compose

Seamless Flow of Android | Local Data Managerment (UDF): SSOT(Single Source of Truth)
When building Modern Android Development (MAD) applications, managing local states like a “Bookmark” or “Favorite” feature can be surprisingly tricky. You need a persistent local storage, a reactive data stream, pagination for smooth UI rendering, and a clean way to handle it all in Jetpack Compose.
In this article, I will walk you through a complete, reactive architecture that stores a list of complex objects in Preferences DataStore using Kotlinx.Serialization (KSP), converts it into a Paging3 stream, and consumes it cleanly in Jetpack Compose.
For more details and the complete implementation of the code snippets discussed above, you can explore the full Phogal_Migrate repository.
Phogal_Migrate Source Code: https://github.com/Lukoh/Phogal_Migrate
The Architecture Overview
Our architecture follows a Unidirectional Data Flow (UDF) with a Single Source of Truth (SSOT):
- Data Layer: Preferences DataStore + Kotlinx.Serialization to save and retrieve a list of complex objects as a JSON string.
- Repository Layer: Converts the retrieved in-memory list into a Paging3
Pager. - ViewModel: Maps the DataStore
Flowto a PagedFlowusingflatMapLatest. - UI Layer: Collects the Paging stream in Jetpack Compose using state holder patterns.
Let’s dive into the code!
Step 1: The Data Layer (DataStore + Serialization)
While Room (SQLite) is the standard for large datasets, Preferences DataStore combined with kotlinx.serialization is a lightweight and blazing-fast alternative for smaller collections like user preferences or moderate bookmark lists.
Here is how we save and toggle a bookmarked photo in our LocalDataSource:
suspend fun toggleBookmarkPhoto(bookmarkedPhoto: Picture) {
context.dataStore.edit { preferences ->
val jsonStr = preferences[PreferencesKeys.BOOKMARK_PHOTOS]
// Deserialize existing list or create a new one
val photos = if (jsonStr.isNullOrEmpty()) {
mutableListOf()
} else {
runCatching {
json.decodeFromString(pictureListSerializer, jsonStr).toMutableList()
}.getOrDefault(mutableListOf())
}
// Toggle logic: Remove if exists, add if it doesn't
val existingPhoto = photos.find { it.id == bookmarkedPhoto.id }
if (existingPhoto == null) {
photos.add(bookmarkedPhoto)
} else {
photos.remove(existingPhoto)
}
// Serialize back to JSON and save
preferences[PreferencesKeys.BOOKMARK_PHOTOS] = json.encodeToString(pictureListSerializer, photos)
}
}
To make this reactive, we expose the DataStore values as a Flow. Notice the use of runCatching—this is a crucial safety measure to prevent application crashes in case of JSON malformation or schema changes.
val bookmarkedPhotosFlow: Flow<List<Picture>> = context.dataStore.data
.map { preferences ->
val jsonStr = preferences[PreferencesKeys.BOOKMARK_PHOTOS]
if (jsonStr.isNullOrEmpty()) {
emptyList()
} else {
runCatching { json.decodeFromString(pictureListSerializer, jsonStr) }
.getOrElse {
Timber.w(it, "Failed to parse stored bookmarks")
emptyList()
}
}
}
Step 2: Bridging to Paging3 (Repository)
Normally, Paging3 is used for network requests or Room database queries. However, to keep our UI components consistent (e.g., if other screens use LazyPagingItems), we can convert our in-memory list into a Paging stream.
In the Repository, we create a Pager that uses a custom BookmarkPagingSource (which loads chunks of data from the provided memory list).
@Singleton
class BookmarkRepositoryImpl @Inject constructor() : BookmarkRepository {
override fun bookmarks(bookmarks: List<Picture>, pageSize: Int): Flow<PagingData<Picture>> {
return Pager(
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false),
pagingSourceFactory = { BookmarkPagingSource(bookmarks) } // Custom in-memory PagingSource
).flow
}
}
Step 3: Managing Streams in the ViewModel
This is where the magic happens. We need to react to changes in the DataStore and seamlessly generate a new Paged stream whenever a bookmark is added or removed.
// 1. Observe the DataStore stream and convert it to a StateFlow
val photos: StateFlow<List<Picture>> = localDataSource.bookmarkedPhotosFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
// 2. React to changes and map to PagingData
@OptIn(ExperimentalCoroutinesApi::class)
val bookmarkedPictures: StateFlow<PagingData<Picture>> = photos
.flatMapLatest { photos ->
// Whenever the DataStore list changes, recreate the Pager
bookmarkRepository.bookmarks(photos.toMutableList(), pageSize = PAGE_SIZE)
}
.cachedIn(viewModelScope) // Crucial for maintaining Paging state across configuration changes
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS),
initialValue = PagingData.empty()
)
Why flatMapLatest? Whenever the user toggles a bookmark, photos emits a new list. flatMapLatest cancels the previous Pager stream and starts a new one with the updated list. This perfectly fulfills the Unidirectional Data Flow pattern.
Step 4: Consuming in Jetpack Compose
To keep our Compose UI clean and modular, we abstract the state management into State Holder functions using remember.
@Composable
fun rememberBookmarkUiState(
bookmarkViewModel: BookmarkViewModel
): BookmarkUiState {
// Collect the Paging stream specifically for Compose Lazy lists
val bookmarkedPictures = bookmarkViewModel.bookmarkedPictures.collectAsLazyPagingItems()
return remember(bookmarkedPictures) {
BookmarkUiState(
bookmarkedPictures = bookmarkedPictures,
)
}
}
@Composable
fun rememberBookmarkContentUiState(
bookmarkViewModel: BookmarkViewModel,
baseUiState: BaseUiState = rememberBaseUiState(),
enabledLoadPhotos: MutableState<Boolean> = rememberSaveable { mutableStateOf(true) }
): BookmarkContentUiState {
val bookmarkUiState = rememberBookmarkUiState(bookmarkViewModel)
return remember(baseUiState, bookmarkUiState, enabledLoadPhotos) {
BookmarkContentUiState(
baseUiState = baseUiState,
bookmarkUiState = bookmarkUiState,
_enabledLoadPhotos = enabledLoadPhotos
)
}
}
By encapsulating collectAsLazyPagingItems() inside a remembered UiState class, our actual UI components (like LazyColumn or LazyVerticalGrid) remain dumb, stateless, and incredibly easy to test or preview.
Conclusion & Trade-offs
This architecture provides a highly reactive, clean, and modern approach to handling local favorites. Because DataStore acts as our Single Source of Truth, the UI will automatically and cleanly update whenever a bookmark is toggled — without any manual callbacks or UI-side list manipulation.
A quick note on scalability: Saving a JSON string in Preferences DataStore is incredibly fast for small to medium lists (e.g., a few hundred items). However, because we are serializing and deserializing the entire list on every modification, this approach might face performance bottlenecks if the bookmark list grows into the tens of thousands. If you anticipate massive datasets, migrating the Data Layer to Room (which supports Paging3 natively) would be the recommended next step.
Happy coding!
AndroidAndroid App DevelopmentAndroid Jetpack ComposeAndroid App DevelopersAndroid Apps
메타데이터
- post_id
- 1ea823cf97c2
- slug
- managing-local-data-with-datastore-kotlinx-serialization-paging3-and-compose-1ea823cf97c2
- url
- https://medium.com/@lukohnam/managing-local-data-with-datastore-kotlinx-serialization-paging3-and-compose-1ea823cf97c2
- canonical_url
- https://medium.com/@lukohnam/managing-local-data-with-datastore-kotlinx-serialization-paging3-and-compose-1ea823cf97c2
- author_url
- https://medium.com/@lukohnam
- status
- ok
- fetched_at
- 2026-08-31 12:47:55