Article
Charting a Course from Android Compose Nav2 to Nav3: Manually Inject Navigation Arguments
July 27, 2026
One of the first things to address in a Nav2 → Nav3 migration is how navigation arguments are provided to ViewModels. Nav3 has a specific recipe for this, which was the main inspiration for the pattern I ultimately implemented.
In many past navigation implementations (Compose Nav2 included), the navigation component supplies the SavedStateHandle with navigation arguments, and ViewModels are generally responsible for knowing how to read those arguments from an injected SavedStateHandle:
@HiltViewModel
class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val userRepository: UserRepository
) : ViewModel() {
private val route: ProfileRoute = savedStateHandle.toRoute<ProfileRoute>()
val user = userRepository.getUser(route.userId)
}This pattern works, but it couples the ViewModel directly to navigation-managed state. The ViewModel does not receive its inputs explicitly. Instead, it has to know how to pull them out of the navigation layer. In my experience, this is a major source of confusion for new devs — why does this SavedStateHandle even have my nav args? Eventually we just accept that this is a magic part of the nav component.
In Nav3, this pattern is thrown overboard; its navigation component no longer populates the SavedStateHandle. Route arguments are resolved at each screen’s entry point. I then recommend passing them to the ViewModel at creation time via factory injection.
Back Stack Management Changes
In Nav3 back stack management becomes very hands-on. This is a major benefit of the new paradigm but it makes talking about navigation a bit more abstract. Ultimately, you just need to provide a set of NavEntrys to the NavDisplay, but I will avoid going deeper into that as it is beyond the scope of this article. I think 90% of use cases will look something like this:
val backStack = rememberNavBackStack(HomeRoute)
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry<HomeRoute> { ... }
entry<ProfileRoute> { ... }
...
},
...,
)The two relevant parameters are:
backStack: A collection of keys that represents the state that needs to be handled by theNavDisplay. This is what we manipulate for navigation!entryProvider: The lambda used to construct each possibleNavEntry. This is where we build the screens associated with each navigation route.
Nuts and Bolts
For the following examples, we will assume you are employing Hilt dependency injection (Hilt, now there is something natural for new devs…). Importantly, we will use Hilt’s assisted injection, for which there is plenty of documentation and precedent; this link can get you started. We will also use typed navigation with the following ProfileRoute type.
@Serializable
data class ProfileRoute(
val userId: String,
)In Nav2
A common pattern looks like this:
- Define the type-safe
composabledestination
composable<ProfileRoute> {
val viewModel: ProfileViewModel = hiltViewModel()
ProfileScreen(viewModel)
}- Navigate with navigation arguments, creating a back stack entry that stores and provides the argument
val route = ProfileRoute(...)
navController.navigate(route)- Read navigation arguments inside the ViewModel from
SavedStateHandle
@HiltViewModel
class ProfileViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val userRepository: UserRepository
) : ViewModel() {
private val route: ProfileRoute = savedStateHandle.toRoute<ProfileRoute>()
val user = userRepository.getUser(route.userId)
}This makes navigation arguments feel convenient, but it also hides an important dependency. The ViewModel depends on a navigation argument, but that dependency is not visible in its constructor — it is impossible to see this dependency in the ProfileViewModel‘s public contract. If the VM somehow did not receive a route the app would crash.
In Nav3
Here, the preferred model is:
- Define a
ViewModeland an associated factory, so you can construct theViewModelwith both its injected dependencies and its navigation inputs
@HiltViewModel(assistedFactory = ProfileViewModel.Factory::class)
class ProfileViewModel @AssistedInject constructor(
@Assisted private val route: ProfileRoute,
private val userRepository: UserRepository
) : ViewModel() {
val user = userRepository.getUser(userId)
@AssistedFactory
interface Factory {
fun create(route: ProfileRoute): ProfileViewModel
}
}
// This can be made generic if you use a `BaseViewModel`.
@Composable
fun profileViewModel(
route: ProfileRoute,
): ProfileViewModel =
hiltViewModel<ProfileViewModel, ProfileViewModel.Factory>(
creationCallback = { factory -> factory.create(route) },
)- Define the type-safe
entrydestination, handle resolving the navigation argument, then pass the data off to theViewModelfactory
entry<ProfileRoute> { route ->
val viewModel = profileViewModel(
route = route,
)
ProfileScreen(viewModel)
}- Navigate with navigation arguments, creating a back stack entry that stores and provides the argument
val route = ProfileRoute(...)
backStack.add(route)This makes the ViewModel’s required inputs explicit and shifts responsibility to the correct layer:
- Navigation owns route parsing
- Dependency injection owns object construction
- The
ViewModeljust receives what it needs
This is a bit more verbose, but in some ways it should be — it makes explicit something that was previously abstracted away from us behind the navigation component.
Conclusion
Manual Injection is a Better Pattern
Moving away from SavedStateHandle for primary argument provision has a few main benefits:
- Dependencies are explicit, typed, and required instead of hidden, casted, and implied
ViewModels are less coupled to navigation internals- Testing is simpler because inputs can be provided directly
SavedStateHandle may still have a place for state restoration, but it no longer works out of the box for delivering navigation arguments into a ViewModel, and therefore it should stop being the default mechanism.
Up next: u se metadata to control navigation animations .
Lucas argues withViewModels and assists with injection at Livefront.