Chanzmao ʕ•ᴥ•ʔ Bear Blog

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:

What Should Be Stored?

Good candidates for SavedStateHandle:

Supported types include:

What About rememberSaveable?

rememberSaveable is often a better fit for purely UI-related state such as:

What Should NOT Be Stored?

Avoid storing:

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.

References

#Android #Jetpack Compose #Kotlin #Programming