Scope of ViewModels in Compose Navigation 3
In Navigation 3, ViewModel scoping works differently compared to Navigation 2. Learn what the difference is and how to change it to match the old behavior.
When I migrated one of my sample apps from Navigation 2 to Compose Navigation 3, I encountered a bug with View Models that took a while to debug.
Jetpack Navigation 3 cover image (source).
At first glance, everything worked. Screens rendered, data loaded, and navigation transitions looked correct. But then I noticed something odd: one of the screen’s ViewModel seemed to not fetch the latest state from backend upon re-entry. It only worked the very first time that screen was opened.
I was triggering the data load in the ViewModel’s init {} block. Adding logs confirmed that it was only called once. Not once per screen visit. Not once per navigation event. Just once per app launch. Moving the load trigger to a LaunchedEffect solved that, but the previous state still persisted across screen re-entries.
Once I read the documentation it became clear why that’s the case and how to fix it. The goal of this post is to share the issue and the solution to help others who encounter it.
Default behavior
In Navigation 3, ViewModel scoping works differently compared to Navigation 2. By default, viewModel() in Compose resolves the ViewModel from the nearest ViewModelStoreOwner.
In a typical single-activity Compose app using Navigation 3, this means that ViewModels are scoped to the Activity, not to individual navigation destinations.
As a result:
- A single instance of a ViewModel is reused across multiple navigations
- The ViewModel survives across screen changes
- Initialization logic (such as
init {}) runs only once per Activity lifecycle
This differs from Navigation 2, where each NavBackStackEntry acted as a ViewModelStoreOwner, and ViewModels were scoped per destination.
Implications
If your ViewModel contains logic like:
1
2
3
init {
loadData()
}
You may notice that:
loadData()is only called once- Navigating away and back to the same screen does not recreate the ViewModel
- UI may display stale or previously loaded data
This is expected behavior under Activity-scoped ViewModels. But how to scope them to a navigation entry?
Scoping to a navigation entry
To scope a ViewModel to a specific navigation destination (similar to Navigation 2 behavior), you must opt in to this behavior using an add-on library.
Navigation 3 provides this via the androidx.lifecycle:lifecycle-viewmodel-navigation3 dependency. It introduces a NavEntryDecorator that creates a ViewModelStoreOwner per navigation entry.
1. Add the dependency
1
2
3
dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0")
}
2. Add required decorators to NavDisplay
To enable NavEntry-scoped ViewModels, you must include both:
rememberSaveableStateHolderNavEntryDecorator()(for state)rememberViewModelStoreNavEntryDecorator()(for ViewModels)
1
2
3
4
5
6
7
8
9
10
NavDisplay(
entryDecorators = listOf(
// Required for saving Compose state per entry
rememberSaveableStateHolderNavEntryDecorator(),
// Required for ViewModel scoping per entry
rememberViewModelStoreNavEntryDecorator()
),
backStack = backStack,
entryProvider = entryProvider { }
)
Once configured, this restores the familiar Navigation 2 behavior:
- Each
NavEntrygets its ownViewModelStoreOwner - A new ViewModel instance is created when the entry is added to the back stack
- The ViewModel is cleared when the entry is removed
init {}runs each time the destination is entered
3. Alternative approach
Instead of relying on init {} for screen-specific work, you can trigger logic from the UI layer using a LaunchedEffect:
1
2
3
LaunchedEffect(Unit) {
viewModel.loadData()
}
This ensures logic runs when the composable enters composition, regardless of ViewModel reuse. Note that your ViewModel state from previous screen open will still persist if you keep the default scope.
Summary
Navigation 3 gives you full control over ViewModel scoping, but requires some setup to behave same as Navigation 2. It’s important to understand how it behaves and what your use case requires.
Use entry-scoped ViewModels when:
- You expect fresh state on each navigation
- The ViewModel represents a single screen instance
- You want lifecycle parity with Navigation 2
Activity-scoped ViewModels are useful for:
- Shared state across multiple screens
- Caching expensive data
- Cross-screen coordination
References: