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.
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:
ViewModelStoreProvider— an interface that acts as a factory forViewModelStoreOwners keyed by an arbitrary value.rememberViewModelStoreProvider()— creates and remembers aViewModelStoreProviderin Compose, surviving configuration changes.rememberViewModelStoreOwner(provider, key)— retrieves (or creates) theViewModelStoreOwnerfor a specific key from the provider. The owner's store is cleared automatically when the composable that called this function leaves the composition permanently.
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.
No comments yet. Be the first to leave one!