A Cleaner Way to Pass Navigation Arguments in Navigation 3
Recently I came across an interesting article about using Hilt Assisted Injection with Jetpack Navigation 3 instead of relying on SavedStateHandle for passing navigation arguments to a ViewModel.
The idea is simple: rather than having a ViewModel read navigation arguments from SavedStateHandle, pass the route object directly through Assisted Injection.
The Traditional Approach
For a long time, many Android apps have used SavedStateHandle to retrieve navigation arguments:
class DetailViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val route = savedStateHandle.toRoute<DetailRoute>()
}
This works well, but it also means the ViewModel knows about navigation-specific APIs.
Conceptually, the dependency flow looks like this:
Screen
↓ navigate()
Navigation
↓
SavedStateHandle
↓
ViewModel
Downsides
- The
ViewModeldepends on Navigation APIs. - Testing often requires setting up a
SavedStateHandle. - Navigation concerns leak into the presentation layer.
- Route changes can affect ViewModel implementation details.
The Navigation 3 Approach
With Navigation 3, the recommended pattern is to inject the route directly into the ViewModel.

@HiltViewModel(assistedFactory = DetailViewModel.Factory::class)
class DetailViewModel @AssistedInject constructor(
repository: Repository,
@Assisted val route: DetailRoute
) : ViewModel()
The dependency flow becomes much simpler:
Screen
↓
Route
↓
Assisted Injection
↓
ViewModel
Benefits
- The
ViewModelonly knows about the route data it needs. - Less coupling to Navigation APIs.
- Easier unit testing.
- Better separation of responsibilities.
- Works naturally with dependency injection.
Example
Route
@Serializable
data class Detail(
val userId: String
) : NavKey
ViewModel
@HiltViewModel(assistedFactory = DetailViewModel.Factory::class)
class DetailViewModel @AssistedInject constructor(
@Assisted private val key: Detail, // dynamic
private val repository: UserRepository // static
) : ViewModel() {
private val _state = MutableStateFlow<User?>(null)
val state: StateFlow<User?> = _state
init {
viewModelScope.launch {
_state.value = repository.getUser(key.userId)
}
}
@AssistedFactory
interface Factory {
fun create(key: Detail): DetailViewModel
}
}
UI (Composable)
NavDisplay(
backStack = backStack,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Detail> { key ->
val viewModel = hiltViewModel<DetailViewModel, DetailViewModel.Factory>(
creationCallback = { factory ->
factory.create(key)
}
)
DetailScreen(viewModel = viewModel)
}
}
)
@Composable
fun DetailScreen(
viewModel: DetailViewModel
) {
val user by viewModel.state.collectAsStateWithLifecycle()
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(user?.name ?: "Loading...")
}
}
Comparing the Two Approaches
| Aspect | SavedStateHandle | Assisted Injection |
|---|---|---|
| Type safety | ✅ | ✅ |
| Testability | ⚠️ | ✅ |
| Navigation coupling | High | Low |
| ViewModel independence | Lower | Higher |
| DI friendliness | Good | Excellent |
Why I Like This Pattern
A ViewModel should primarily manage UI state and business logic.
When it also becomes responsible for retrieving navigation arguments, responsibilities start to overlap.
ViewModel
├ Navigation argument retrieval
├ State management
└ Business logic
With Assisted Injection:
Navigation
└ Route creation
ViewModel
├ State management
└ Business logic
The responsibilities are much clearer.
Things to Keep in Mind
SavedStateHandle.toRoute()is still supported and perfectly valid.- Navigation 3 is relatively new, so patterns may continue to evolve.
- Assisted Injection introduces a bit more setup code.
- For smaller apps, the practical difference may be minimal.
Final Thoughts
The biggest takeaway for me is this:
Navigation 2
→ ViewModel reads arguments from SavedStateHandle
Navigation 3
→ Route is injected directly into the ViewModel
It’s a small architectural shift, but it helps keep ViewModels focused on what they should be doing: managing state, not dealing with navigation infrastructure.