Chanzmao ʕ•ᴥ•ʔ Bear Blog

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 Navigation 3 Approach

With Navigation 3, the recommended pattern is to inject the route directly into the ViewModel.

Navigation 3 Overview

@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


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


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.


References

#Android #Hilt #Jetpack Compose