Skip to main content

Article

Charting a Course from Android Compose Nav2 to Nav3: Manually Inject Navigation Arguments

Lucas Kivi

July 27, 2026

A toucan looking at a globe.
This is a part of a larger guide for migrating from Android Compose Navigation 2 to Navigation 3. Checkout the parent guide for an overview of the migration.

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 the NavDisplay. This is what we manipulate for navigation!
  • entryProvider: The lambda used to construct each possible NavEntry. 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 composable destination
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 ViewModel and an associated factory, so you can construct the ViewModel with 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 entry destination, handle resolving the navigation argument, then pass the data off to the ViewModel factory
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 ViewModel just 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 with ViewModels and assists with injection at Livefront.