← Back to list

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…

Lukoh Nam · 2026-05-05 23:24 · 0 claps · 3.7 min read
#android #android-app-development #android-jetpack-compose #android-app-developers #android-apps
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

Managing Local Data with DataStore, Kotlinx.Serialization, Paging3, and Compose

Seamless Flow of Android | Local Data Managerment (UDF): SSOT(Single Source of Truth)

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):

  1. Data Layer: Preferences DataStore + Kotlinx.Serialization to save and retrieve a list of complex objects as a JSON string.
  2. Repository Layer: Converts the retrieved in-memory list into a Paging3 Pager.
  3. ViewModel: Maps the DataStore Flow to a Paged Flow using flatMapLatest.
  4. 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:

Kotlin https://github.com/Lukoh/Phogal_Migrate/blob/main/app/src/main/java/com/goforer/phogal/data/datasource/local/LocalDataSource.kt

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.

Kotlin https://github.com/Lukoh/Phogal_Migrate/blob/main/app/src/main/java/com/goforer/phogal/data/datasource/local/LocalDataSource.kt

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).

Kotlin https://github.com/Lukoh/Phogal_Migrate/blob/main/app/src/main/java/com/goforer/phogal/data/repository/bookmark/BookmarkRepositoryImpl.kt

@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.

Kotlin https://github.com/Lukoh/Phogal_Migrate/blob/main/app/src/main/java/com/goforer/phogal/presentation/stateholder/business/home/setting/bookmark/BookmarkViewModel.kt

// 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.

Kotlin https://github.com/Lukoh/Phogal_Migrate/blob/main/app/src/main/java/com/goforer/phogal/presentation/stateholder/business/home/setting/bookmark/BookmarkViewModel.kt

@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!

  • Android
  • Android App Development
  • Android Jetpack Compose
  • Android App Developers
  • Android 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