How SavedStateHandle Survives Process Death, and How ViewModel, Hilt, and Navigation Plug Into One Registry
How SavedStateHandle Survives Process Death, and How ViewModel, Hilt, and Navigation Plug Into One Registry
Most Android developers reach for SavedStateHandle when they need a value to outlive process death, then move on. You inject it into a ViewModel, call get and set or getStateFlow, and the system restores the value after the OS kills your backgrounded app. The same SavedStateHandle arrives ready to use whether the ViewModel was created by a ComponentActivity, by Hilt, or by a Navigation destination. That consistency is not luck. It comes from a small set of types in the androidx.savedstate and androidx.lifecycle libraries that every one of those hosts plugs into the same way.
In this article, you'll dive deep into how saved state actually flows, exploring the SavedStateRegistry and its SavedStateProvider contract, the SavedStateRegistryController and the exact lifecycle moments when state is restored and saved, the SavedStateHandlesProvider that bridges the registry to ViewModel scoped handles, the internals of SavedStateHandle itself, the createSavedStateHandle factory path through CreationExtras, and how ComponentActivity, Hilt, Navigation, and Navigation3 each connect a ViewModel to that same registry.
One note before we start: the androidx.savedstate and androidx.lifecycle libraries are now Kotlin Multiplatform, so the persisted type is androidx.savedstate.SavedState. On Android, SavedState is a typealias for android.os.Bundle, so every time you see a Bundle cross the boundary, it is literally a SavedState.
The fundamental problem: Two different kinds of death
Android destroys your UI for two unrelated reasons, and they need different recovery strategies. A configuration change, such as a rotation, destroys and recreates the Activity inside the same process. A ViewModel survives this because the ViewModelStore is retained in memory across the recreation, so the object you get afterward is the same instance.
Process death is different. When the OS reclaims your backgrounded app to free memory, the entire process is gone, and the retained ViewModelStore goes with it. A plain ViewModel cannot help here. The only thing that returns is the Bundle the system handed to onSaveInstanceState, which the framework persisted outside your process and gives back to the recreated Activity.
So you have two tools that each solve only half the problem. A ViewModel survives rotation but not process death. The onSaveInstanceState bundle survives process death but is Activity scoped, manual, and limited to small parcelable data. SavedStateHandle unifies them: it is a map of keys to values, scoped to the ViewModel and backed by the saved state bundle, so a value placed in it survives both kinds of death through one API.
class SearchViewModel(private val handle: SavedStateHandle) : ViewModel() {
val query: StateFlow<String> = handle.getStateFlow("query", "")
fun onQueryChange(text: String) {
handle["query"] = text
}
}
After a rotation this value survives because the ViewModel is retained. After process death it also survives, because the handle is backed by the saved state bundle. The rest of this article traces how that second guarantee is wired, and why the same wiring works identically under Hilt and Navigation.
The registry contract: many providers, one bundle
The foundation is SavedStateRegistryOwner, the interface every host implements. It is a LifecycleOwner that exposes a single registry:
public interface SavedStateRegistryOwner : LifecycleOwner {
public val savedStateRegistry: SavedStateRegistry
}
The registry is a meeting point. Any component that wants to contribute to the saved bundle registers a SavedStateProvider, which is a function that returns a SavedState when asked:
public fun interface SavedStateProvider {
public fun saveState(): SavedState
}
The public SavedStateRegistry delegates its real work to an internal SavedStateRegistryImpl in commonMain. If you examine that class, its state is a map from string keys to providers, a cached bundle of restored state, and a few flags that gate restoration and saving:
internal class SavedStateRegistryImpl(
private val owner: SavedStateRegistryOwner,
internal val onAttach: () -> Unit = {},
) {
private val lock = SynchronizedObject()
private val keyToProviders = mutableScatterMapOf<String, SavedStateProvider>()
private var attached = false
private var restoredState: SavedState? = null
@get:MainThread
var isRestored = false
private set
internal var isAllowingSavingState = true
}
Reading restored state goes through consumeRestoredStateForKey, and the important detail is that it is destructive. Once a key is read, it is removed:
@MainThread
fun consumeRestoredStateForKey(key: String): SavedState? {
check(isRestored) {
"You can 'consumeRestoredStateForKey' only after the corresponding component has " +
"moved to the 'CREATED' state"
}
val state = restoredState ?: return null
val consumed = state.read { if (contains(key)) getSavedState(key) else null }
state.write { remove(key) }
if (state.read { isEmpty() }) {
restoredState = null
}
return consumed
}
Two design choices here matter for everything downstream. First, consumption requires isRestored to be true, so a component cannot read its state before the owner has reached the CREATED state. Second, consumption removes the key, so each piece of restored state is handed out exactly once. This is what lets the registry treat its restored bundle as a queue that drains as each component reaches CREATED and reads its key.
Restoring and saving: the controller and its lifecycle window
A host does not talk to the registry directly for save and restore. It uses a SavedStateRegistryController, a thin surface that forwards to the implementation:
public actual class SavedStateRegistryController
private actual constructor(private val impl: SavedStateRegistryImpl) {
public actual val savedStateRegistry: SavedStateRegistry = SavedStateRegistry(impl)
@MainThread
public actual fun performRestore(savedState: SavedState?) {
impl.performRestore(savedState)
}
@MainThread
public actual fun performSave(outBundle: SavedState) {
impl.performSave(outBundle)
}
}
The restore step takes the incoming bundle, pulls out the registry's own slice, and flips isRestored. Everything the registry owns lives under one namespaced key, SAVED_COMPONENTS_KEY:
@MainThread
internal fun performRestore(savedState: SavedState?) {
if (!attached) {
performAttach()
}
check(!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
"performRestore cannot be called when owner is ${owner.lifecycle.currentState}"
}
restoredState =
savedState?.read {
if (contains(SAVED_COMPONENTS_KEY)) getSavedState(SAVED_COMPONENTS_KEY) else null
}
isRestored = true
}
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor