SavedStateHandle in the Navigation 3 Era

With Navigation 3, the role of SavedStateHandle has become much clearer.
Use NavKey for navigation and screen arguments.
Use ViewModel for normal UI state management.
Use SavedStateHandle only for lightweight UI state that should survive process death.
Responsibilities
NavKey
-> Navigation and screen arguments
ViewModel
-> Regular UI state
-> Survives configuration changes
SavedStateHandle
-> Lightweight state restored after process death
A common mistake is putting everything into SavedStateHandle.
In most cases, your ViewModel survives screen rotations, so there is no need to persist every piece of state.
Instead:
- Navigation arguments belong to NavKey
- Runtime state belongs to ViewModel
- Process-death recovery belongs to SavedStateHandle
What Should Be Stored?
Good candidates for SavedStateHandle:
- Search query
- Filters and sort options
- Selected tab
- Form inputs
- Current pager page
- …
Supported types include:
- String
- Int
- Long
- Boolean
- Float
- Double
- Enum
- ArrayList
- Parcelable
- Serializable
What About rememberSaveable?
rememberSaveable is often a better fit for purely UI-related state such as:
- Scroll position
- Expanded/collapsed state
- Temporary UI flags
What Should NOT Be Stored?
Avoid storing:
- Navigation arguments (Navigation 3 era)
- API responses
- Search results
- Large collections
- Repository data
Those belong elsewhere and can usually be recreated.
Example
data class SearchUiState(
val query: String = "",
val filter: Filter = Filter.All,
val sort: Sort = Sort.Newest,
val loading: Boolean = false,
...
)
class SearchViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val query = savedStateHandle.getStateFlo(
key = "query",
initialValue = ""
)
private val filter = savedStateHandle.getStateFlow(
key = "filter",
initialValue = Filter.All
)
private val sort = savedStateHandle.getStateFlow(
key = "sort",
initialValue = Sort.Newest
)
private val loading = MutableStateFlow(false)
val uiState: StateFlow<SearchUiState> = combine(
query, filter, sort, loading
) { query, filter, sort, loading ->
SearchUiState(
query = query,
filter = filter,
sort = sort,
loading = loading
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchUiState()
)
fun onQueryChanged(query: String) {
savedStateHandle["query"] = query
}
fun onFilterChanged(filter: Filter) {
savedStateHandle["filter"] = filter
}
fun onSortChanged(sort: Sort) {
savedStateHandle["sort"] = sort
}
fun setLoading(value: Boolean) {
loading.value = value
}
}
A Practical Improvement
In real projects, it is often cleaner to separate persistent search criteria from transient UI state.
data class SearchCriteria(
val query: String = "",
val filter: Filter = Filter.All,
val sort: Sort = Sort.Newest
)
Then:
SavedStateHandle
-> SearchCriteria
ViewModel Memory
-> loading
-> results
-> error
-> paging state
This keeps process-death restoration focused on user intent rather than the entire screen state.
Takeaway
The Navigation 3 mindset is simple:
NavKey -> Where am I going?
ViewModel -> What is happening right now?
SavedStateHandle -> What must survive process death?
If a value can be recreated, don’t save it.
If it represents user input that would be frustrating to lose, SavedStateHandle is probably the right place for it.