Lifecycle 8 min read

Lifecycle 2.11.0: Scoped ViewModels Per Pager Page — The API Android Developers Have Needed for Years

If you've ever built a HorizontalPager or VerticalPager where each page needs its own ViewModel — one that survives a configuration change but gets cleaned up when the page is permanently gone — you know exactly how awkward this has been. The standard viewModel() call inside a Pager doesn't scope to the page; it scopes to whatever ViewModelStoreOwner is nearest in the hierarchy, which is usually the entire screen. Clearing it when a page is swiped away requires workarounds. Scoping it to survive config changes requires more workarounds on top of the first ones.

Lifecycle 2.11.0, which hit stable on June 17, 2026, ships the proper fix: a first-party API for creating ViewModelStoreOwners that are tied to Composable lifetime rather than to Navigation back-stack entries or Activity/Fragment scope.

2.11.0
Stable since June 17, 2026
New
ViewModelStoreProvider for Compose Pager scoping
KMP
ViewModel-Compose on all multiplatform targets

The Problem This Solves

Consider Samachar, my news reader app. The home screen is a multi-tab HorizontalPager — one page per news category. Each page fetches its own data, holds its own scroll position, and manages its own loading/error state. The natural home for all of that state is a ViewModel scoped to the page.

The problem: viewModel() inside a Compose Pager defaults to the nearest ancestor ViewModelStoreOwner above the Pager, which is the host Activity or Fragment. That means all five category pages share one ViewModel store — either you call viewModel(key = "page-$index") to get separate instances (but they all share the same store and all clear together), or you build something custom. And getting config-change survival right on top of that is genuinely messy.

The workaround most teams reach for is a manually maintained Map<Int, ViewModelStore> keyed by page index, cleaned up with DisposableEffect when the page count changes. It works, but it's ceremony the framework should own, not you. This pattern shows up in production apps doing anything with a pager — Musist's album-by-album browser, Samachar's category tabs, any onboarding flow with a screen-per-step ViewModel.

The New APIs

Lifecycle 2.11.0 introduces three new APIs that work together:

Here's how it looks wired up to a news category Pager:

@Composable
fun NewsCategoryScreen(categories: List<NewsCategory>) {
    // Hoist the provider above the Pager so it outlives individual pages
    val provider = rememberViewModelStoreProvider()
    val pagerState = rememberPagerState(pageCount = { categories.size })

    HorizontalPager(state = pagerState) { page ->
        // Each page gets its own ViewModelStoreOwner, keyed by page index
        val storeOwner = rememberViewModelStoreOwner(provider, key = page)

        CompositionLocalProvider(LocalViewModelStoreOwner provides storeOwner) {
            // viewModel() now resolves to a ViewModel scoped to this page only
            val viewModel: CategoryFeedViewModel = viewModel()
            CategoryFeedPage(viewModel = viewModel)
        }
    }
}

What you get: each page's ViewModel survives a configuration change (the ViewModelStoreProvider is remembered across recomposition), and it clears automatically when the page's ViewModelStoreOwner leaves the composition permanently — no DisposableEffect, no manual map management, no guessing when to call ViewModelStore.clear().

The key structural point is that rememberViewModelStoreProvider() is hoisted above the Pager. This is intentional: the provider needs to survive configuration changes and the paging lifecycle, so it lives where its scope allows that. Individual page owners are then derived from it per-key inside the HorizontalPager content block.

Use a stable key when items can shift

If your pages aren't index-stable — say, items can be added or removed from the list — keying by page index will assign the wrong ViewModel when the list changes. Key by something semantically stable instead:

HorizontalPager(state = pagerState) { page ->
    val category = categories[page]
    // Key by category ID, not page index
    val storeOwner = rememberViewModelStoreOwner(provider, key = category.id)

    CompositionLocalProvider(LocalViewModelStoreOwner provides storeOwner) {
        val viewModel: CategoryFeedViewModel = viewModel()
        CategoryFeedPage(viewModel = viewModel, category = category)
    }
}

This pattern is directly applicable to Musist's album browser — each album page needs its own playback ViewModel tracking position and buffering state. Keying by album.id means the ViewModel survives swiping back to a previously visited album, and clears when the album genuinely leaves the list. The intent is explicit in the key, which also makes debugging easier: you can see exactly what scope you meant.

Navigation3 Integration

If you're evaluating Navigation3 alongside this release, there's a related change worth noting. ViewModelStoreNavEntryDecorator now accepts a ViewModelStoreOwner parameter, which lets you propagate custom CreationExtras and ViewModel factories through the decorator chain. Previously it used a hard-coded default factory, which made injecting Hilt-provided factories more awkward.

On the cleanup side, removeViewModelStoreOnPop is deprecated in 2.11.0. The replacement is the ViewModelStoreNavEntryDecorator with proper scoping — the new model handles pop-cleanup as a natural consequence of scope tracking, rather than a flag you have to set and remember.

KMP Support for ViewModel-Compose

Both ViewModel-Compose and ViewModel-Navigation3 now support all Kotlin Multiplatform targets. Previously these Compose integration layers were JVM/Android-only, which meant teams sharing UI logic across Android and desktop targets had to maintain separate ViewModel-binding code per platform or reach for a third-party solution.

In practice this matters most if you're actively using Compose Multiplatform — the viewModel() call and its associated scoping now work the same way on Android, desktop JVM, and other supported targets. If you're Android-only, this is background information for now but useful to know before you consider adding a desktop target.

Quality-of-Life Additions

Three smaller ergonomic improvements round out the release:

Reified ViewModelProvider.get<VM>(key) — you can now fetch a ViewModel instance with an explicit key using reified type parameters, which removes the need to pass a Class reference:

// Before: explicit class reference required
val vm = ViewModelProvider(owner).get(CategoryFeedViewModel::class.java, "key-a")

// After: reified type parameter inferred
val vm = ViewModelProvider(owner).get<CategoryFeedViewModel>("key-a")

Lambda-based Lifecycle.addObserver — observing individual lifecycle events no longer requires implementing a LifecycleObserver interface. You can pass a lambda for the specific event you care about:

lifecycle.addObserver(Lifecycle.Event.ON_RESUME) {
    analytics.trackScreenVisible(screenName)
}

lifecycle.addObserver(Lifecycle.Event.ON_PAUSE) {
    mediaPlayer.pause()
}

This is the pattern I'd actually reach for in most cases — you rarely need to observe every lifecycle event, and the boilerplate of a full LifecycleEventObserver implementation for a one-liner is hard to justify.

ViewModel.onCleared annotated with @EmptySuper — overriding onCleared() and forgetting to call super.onCleared() is a long-standing source of IDE warnings. The @EmptySuper annotation signals that the base implementation is intentionally empty, which suppresses the "missing super call" warning without requiring you to add a meaningless super.onCleared() line. Existing code keeps working; you just stop getting nagged about it.

Minimum requirements: Compose UI 1.7.0+ is now required for LocalLifecycleOwner — the reflection fallback that allowed older Compose versions to resolve it has been removed. Also check that your AGP version is at least 9.2.0 before bumping; the updated Compose compileSdk in this release requires it.

Is This Worth Upgrading For?

If you have a Pager in your app — and most production Android apps do — yes, this is worth bumping for. The rememberViewModelStoreProvider / rememberViewModelStoreOwner pattern removes an entire category of "why is my ViewModel not clearing?" or "why did my ViewModel clear when I rotated the device?" bugs rather than just papering over one specific case.

The approach is also cleaner than what it replaces from a mental-model standpoint. ViewModelStoreOwner is not a new concept — every time you call viewModel(), you're implicitly using one. What 2.11.0 adds is the ability to create lightweight, key-scoped instances of it that integrate with Compose's composition lifetime, rather than being forced to use Navigation back-stack entries or host Activity scope as a proxy for "the scope of this page."

The KMP expansion matters if you're on Compose Multiplatform. The quality-of-life additions (reified get, lambda observer, EmptySuper) are pure ergonomics and cost nothing to pick up. Verify the Compose 1.7.0+ and AGP 9.2.0 requirements before merging — if you're already there (which you should be on both by now), it's a clean bump with no migration needed.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment