Handling Dynamic In-Memory Mutations in Jetpack Paging 3 Without a Database

Likes that toggle instantly. Deleted posts that vanish on tap. New posts that appear at the top — all on a paginated feed, with no Room database in sight.

Handling Dynamic In-Memory Mutations in Jetpack Paging 3 Without a Database

Likes that toggle instantly. Deleted posts that vanish on tap. New posts that appear at the top — all on a paginated feed, with no Room database in sight.


About the code in this article

Everything here comes from a real, open-source app — not a contrived snippet. WC26 Threads is an Android social feed I built as a portfolio project: a Threads-style app for talking about the 2026 World Cup, written in Kotlin with Jetpack Compose, Paging 3, Hilt, and a cursor-paginated backend.

The full source is on GitHub, so you can open every file referenced below and see it in context:

Repo: github.com/AdelAchour/wc26-android

The five files we’ll walk through:

Snippets below are lightly trimmed for readability; the repo has the unabridged versions. With that, let’s dig in.


The problem nobody warns you about

Jetpack Paging 3 is excellent at one thing: streaming a long, cursor-paginated list from the network into a LazyColumn without you babysitting page boundaries. You hand it a PagingSource, it hands you a Flow<PagingData<T>>, and scrolling Just Works.

Then your designer asks for a like button.

Suddenly you need a single item — buried somewhere on page 3 of a stream you don’t control — to update immediately when the user taps it. No spinner, no full refresh, no scroll jump. Just the heart filling in and the count ticking up.

This is where most tutorials quietly point you to Room. The “blessed” path for mutable paginated data is RemoteMediator + a local database: write the page into SQLite, let Room’s PagingSource observe the table, mutate a row, and the UI recomposes. It works, but it’s a lot of machinery — a database, DAOs, entity mappers, a mediator — just so a like button can flip.

The catch most people miss: PagingData is immutable and effectively single-shot. Once a page is emitted downstream you can’t reach into it and edit item 47. And PagingData is not a normal collection — you can’t .indexOf() it, you can’t hold a MutableList of it, and you definitely shouldn’t try to cache it yourself. So how do you mutate something you can’t touch?

This article walks through a database-free approach used in a real World Cup social feed. The trick is to stop thinking about mutating the list and start thinking about layering transformations on top of an immutable stream. We lean on three things:

  1. A cached Pager flow as the source of truth for list structure (what’s on which page, scroll position, load states).
  2. Small, separate StateFlows as the source of truth for mutations (which posts are liked, which are deleted).
  3. combine, PagingData.map, and PagingData.filter to stitch them together in-flight, every time either side changes.

Let’s build it up.


The mental model: structure vs. mutations

Here’s the key insight. There are really two different kinds of state in a feed like this, and they change for different reasons:

What it ownsWhen it changesWho owns it
StructureWhich items exist, their order, pagination, scrollWhen you scroll or refreshPaging 3
MutationsLiked state, like counts, deletionsWhen the user taps somethingYou

The mistake is trying to make Paging 3 own both. It’s bad at the second one. So instead we keep them in separate streams and merge them at read time. Paging owns the heavy, scroll-sensitive structure. Tiny in-memory maps own the cheap, frequently-changing mutations. Neither one has to know about the other until the moment we render.

This is the whole idea. Everything below is just plumbing.


Layer 1: The PagingSource (structure)

First, the boring-but-important part — getting pages from the network. The PostPagingSource is deliberately thin:

class PostPagingSource(
    private val fetch: suspend (cursor: String?) -> DataResult<CursorPage<Post>>,
) : PagingSource<String, Post>() {

    override suspend fun load(
        params: LoadParams<String>,
    ): LoadResult<String, Post> {
        val cursor = params.key // null on first load
        return when (val result = fetch(cursor)) {
            is DataResult.Success -> LoadResult.Page(
                data = result.data.items,
                prevKey = null, // forward-only paging
                nextKey = result.data.nextCursor,
            )
            is DataResult.Error -> LoadResult.Error(
                result.cause ?: IllegalStateException(result.error.name),
            )
        }
    }

    override fun getRefreshKey(state: PagingState<String, Post>): String? = null
}

A few design choices worth calling out:

  • The key is a String, not an Int. The backend uses cursor pagination — each page response carries an opaque nextCursor token (a base64 string) that you pass back to fetch the following page. So PagingSource<String, Post>: the key type is the cursor. First load passes null; each LoadResult.Page reports the next cursor as nextKey.

  • fetch is injected as a lambda. The source doesn’t know what it’s paging. Pass it { cursor -> repo.getGlobalFeed(cursor) } and it’s the global feed; pass it a single match’s endpoint and the exact same class pages a match thread. One source, many feeds.

  • getRefreshKey returns null on purpose. With cursor paging you can’t cheaply resume from an arbitrary middle anchor, so on refresh we just restart from the top. For a “newest first” feed that’s not a compromise — it’s the correct behavior. (For an int-keyed list you’d compute an anchor here instead.)

That’s the entire structural layer. It knows how to fetch pages and nothing else. No likes, no deletes — it doesn’t even know those features exist.


Layer 2: The mutation stores (in-memory state)

Now the interesting half. Instead of editing list items, we keep a tiny record of every change the user has made, keyed by post ID. Two singleton managers hold these as StateFlows.

Likes — an optimistic map

data class LikeStatus(
    val likedByCurrentUser: Boolean,
    val likeCount: Int
)

@Singleton
class PostLikeManager @Inject constructor(
    private val likeRepository: LikeRepository,
) {
    // postId -> the user's locally-modified like state
    private val _likedStates = MutableStateFlow<Map<Long, LikeStatus>>(emptyMap())
    val likedStates: StateFlow<Map<Long, LikeStatus>> = _likedStates.asStateFlow()

    fun toggleLike(post: Post, scope: CoroutineScope) {
        val postId = post.id
        // Start from any existing override, else the post's server values
        val currentState = _likedStates.value[postId] ?: LikeStatus(
            likedByCurrentUser = post.likedByCurrentUser,
            likeCount = post.likeCount
        )

        val wasLiked = currentState.likedByCurrentUser
        val nextLiked = !wasLiked
        val nextCount = currentState.likeCount + if (nextLiked) 1 else -1
        val targetState = LikeStatus(nextLiked, nextCount)

        // 1. Optimistic update — UI flips instantly
        _likedStates.update { it + (postId to targetState) }

        // 2. Network call — roll back the map entry if it fails
        scope.launch {
            val result = if (wasLiked) likeRepository.unlike(postId)
                         else likeRepository.like(postId)
            if (result is DataResult.Error) {
                _likedStates.update { it + (postId to currentState) }
            }
        }
    }
}

The map is an overlay of diffs, not a copy of the feed. It only ever contains posts the user has personally touched this session. Tap like on three posts and the map has three entries, no matter how many thousand posts you’ve scrolled past.

Notice the optimistic-update pattern falls out for free: we mutate the map first so the UI reacts immediately, then fire the network call, and if it errors we simply put the old LikeStatus back. Because the map is a StateFlow, that rollback is itself just another emission — the UI un-flips automatically. No special error path in the View layer.

Deletions — a set of IDs

Deletion is even simpler. We don’t need a status object, just “is this gone?”:

@Singleton
class PostDeletionManager @Inject constructor(
    private val postRepository: PostRepository,
    private val commentRepository: CommentRepository,
) {
    private val _deletedPostIds = MutableStateFlow<Set<Long>>(emptySet())
    val deletedPostIds: StateFlow<Set<Long>> = _deletedPostIds.asStateFlow()

    fun deletePost(postId: Long, scope: CoroutineScope, onFailure: () -> Unit = {}) {
        _deletedPostIds.update { it + postId }  // optimistic: hide it now
        scope.launch {
            val result = postRepository.deletePost(postId)
            if (result is DataResult.Error) {
                _deletedPostIds.update { it - postId }  // failed: bring it back
                onFailure()
            }
        }
    }
}

Same shape: optimistic add, rollback on failure. The post disappears the instant you tap delete; if the server rejects it, the ID drops out of the set and the post reappears.

Why are these @Singleton and not just fields in the ViewModel? Because the same like or deletion needs to be reflected in multiple screens — the global feed, a match thread, a post detail page — and they should all agree. A shared singleton store means a like on the detail screen is already reflected when you navigate back to the feed. The mutation state outlives any single ViewModel.


Layer 3: Stitching it together with combine

Now the payoff. The ViewModel holds the cached pager and merges the two mutation stores on top of it:

// Cache the raw pager stream once, tied to the ViewModel's lifetime
private val pagingFlow = Pager(
    config = PagingConfig(pageSize = 20, enablePlaceholders = false),
    pagingSourceFactory = {
        val source = PostPagingSource { cursor ->
            postRepository.getGlobalFeed(cursor)
        }
        currentPagingSource = source   // keep a handle so we can invalidate later
        source
    },
).flow.cachedIn(viewModelScope)

// The flow the UI actually collects
val posts: Flow<PagingData<Post>> = pagingFlow
    .combine(postLikeManager.likedStates) { pagingData, likedStates ->
        pagingData.map { post ->
            likedStates[post.id]?.let { status ->
                post.copy(
                    likedByCurrentUser = status.likedByCurrentUser,
                    likeCount = status.likeCount
                )
            } ?: post
        }
    }
    .combine(postDeletionManager.deletedPostIds) { pagingData, deletedIds ->
        pagingData.filter { post -> post.id !in deletedIds }
    }

Read it top to bottom:

cachedIn(viewModelScope) is doing heavy lifting. It’s what makes this whole pattern legal. Normally a PagingData can only be collected once, and any operator chain re-runs the network on every downstream re-collection. cachedIn materializes the pages and makes the stream shareable and replayable — so we can layer combine/map/filter on top without re-fetching, and the cached pages survive configuration changes. This line must come before the combines. Cache the structure, then transform.

The first combine applies likes. Every time either the pages change (you scrolled, fetched a new page) or likedStates changes (you tapped a heart), the lambda re-runs. Inside, PagingData.map transforms each item: if the post’s ID has an override in the map, we copy() it with the new liked flag and count; otherwise we pass the original through untouched. The server data is the default; the map wins where it has an opinion.

The second combine applies deletions. It takes the already-like-adjusted PagingData and runs PagingData.filter, dropping any post whose ID is in the deleted set. Filtered-out items never reach the UI.

Crucially, PagingData.map and PagingData.filter (from androidx.paging) are page-aware. They transform items lazily, page by page, as Paging loads and presents them — they don’t force the whole list into memory, and they preserve all the load-state and placeholder machinery. You get the ergonomics of List.map { } with none of the cost of flattening a paginated stream.

Why combine is exactly the right operator

combine fires whenever any of its inputs emits, always pairing the newest value from each side. That’s precisely the semantics we want:

  • New page loads → newest PagingData pairs with the current like/delete state → new items get the user’s mutations applied as they appear.
  • User taps like → newest likedStates pairs with the current cached PagingData → that one item recomposes in place, no scroll jump, no refetch.

The two sources update on completely independent schedules and combine doesn’t care. It re-derives the merged view from whatever the latest of each is. The transformations are pure and re-run cheaply, so re-deriving on every keystroke-fast like tap is a non-issue.

   ┌─────────────────────┐
   │  Pager.flow         │  structure: pages, order, scroll
   │  .cachedIn(scope)   │
   └──────────┬──────────┘

        combine(likedStates)  ──►  map { apply LikeStatus overrides }

        combine(deletedPostIds) ──►  filter { drop deleted IDs }


        Flow<PagingData<Post>>  ──►  collected by the UI

Bonus: making new posts appear

There’s a fourth mutation — creating a post. You can’t map a new item into existence, because it isn’t in any page yet. For inserts, the in-memory-overlay trick doesn’t apply; you genuinely need Paging to go fetch fresh data. That’s what invalidate() is for:

@Singleton
class PostCreationNotifier @Inject constructor() {
    private val _postCreated = MutableSharedFlow<Post>(extraBufferCapacity = 1)
    val postCreated = _postCreated.asSharedFlow()
    fun notifyPostCreated(post: Post) { _postCreated.tryEmit(post) }
}
init {
    viewModelScope.launch {
        postCreationNotifier.postCreated.collect {
            currentPagingSource?.invalidate()
        }
    }
}

When a post is created anywhere in the app, the notifier emits, and the ViewModel invalidates the live PagingSource. Paging tears it down, builds a fresh one via the factory, and reloads from the top cursor — so the new post shows up in its proper place. This is why the factory stashes currentPagingSource: it’s the only handle you get to the otherwise Paging-owned source.

The rule of thumb that emerges:

  • Edit or hide existing items → overlay a StateFlow and map/filter it in. Cheap, instant, no network.
  • Add brand-new itemsinvalidate() and let Paging refetch. There’s no shortcut, and you shouldn’t fake one.

Why this beats the database approach (and when it doesn’t)

What you get:

  • No Room, no DAOs, no entities, no RemoteMediator. For a feed that’s inherently online and ephemeral, persisting every post to SQLite just to mutate a like is enormous overkill.
  • Instant, optimistic UI with automatic rollback, for free, because every mutation is a StateFlow emission.
  • Reusable mutation stores. The singleton managers keep likes and deletions consistent across every screen that shows the same post.
  • Pure, testable transformations. The combine lambdas are pure functions of (pages, overrides). Easy to reason about, easy to unit test.

The trade-offs, honestly:

  • State is session-scoped. The overlay maps live in memory. Kill the process and they’re gone — but so is the paged feed itself, which reloads from the network anyway, returning the now-correct server state. For a live feed that’s fine. If you needed offline persistence, this is where Room would earn its keep.
  • The overlay grows with interactions, not list size. It’s bounded by how many distinct items the user touches, not how far they scroll — so in practice it stays tiny. But it never self-prunes within a session.
  • Inserts still cost a refresh. As shown, new items mean invalidate(). There’s no in-place insert into PagingData.

Takeaways

If you remember one thing: don’t try to mutate PagingData — layer over it.

Paging 3 feels rigid because people ask it to own mutable item state, which it was never designed for. Split the responsibilities instead:

  1. Let Pager + cachedIn own structure — pages, order, scroll, load states.
  2. Let small StateFlow overlays own mutations — keyed maps of likes, sets of deleted IDs, updated optimistically with free rollback.
  3. Merge them at read time with combine + PagingData.map / filter, re-deriving the visible list whenever either side changes.
  4. Reach for invalidate() only for the one case overlays can’t handle: genuinely new items.

The result is a paginated feed where likes flip instantly and deletes vanish on tap — with not a single line of SQL.


All code in this article is from WC26 Threads, my open-source World Cup social feed: github.com/AdelAchour/wc26-android. Files referenced: FeedViewModel.kt, PostPagingSource.kt, PostLikeManager.kt, PostDeletionManager.kt, PostCreationNotifier.kt.