Skip to content
AKarda-kazancimain
Personal blog
AK
arda-kazanci › journal ›savedstatehandle-savedstatecontainer.md
← Journal.md
savedstatehandle-savedstatecontainer.md186 lines · Markdown · UTF-8
Markdown Preview · savedstatehandle-savedstatecontainer.md Rendered
journal/savedstatehandle-savedstatecontainer.md

Nested SavedStateHandles: Isolating Child State Without Key Prefixes

Isolate reusable state holders inside a single ViewModel with nested SavedStateHandles, SavedStateContainer integration, and the new StateFlow-LiveData rules.

Suppose a checkout screen has one CheckoutViewModel managing two address forms: shipping and billing. Both forms use the same AddressFormStateHolder class, and both need their text inputs to survive process death.

Until now, a ViewModel received a single flat SavedStateHandle. Passing that same handle to two instances of AddressFormStateHolder meant both wrote to "street" and "city", overwriting each other’s state.

The usual workaround was string prefixing: pass "shipping" and "billing" into the constructor, then build keys like "${prefix}_street" and "${prefix}_city" by hand. That works, though every reusable state holder has to invent its own naming scheme and hope no parent key collides with it.

AndroidX Lifecycle commit 8f4994031e712166d1df58a75f9dae2669424e95 solves this directly by migrating SavedStateHandle onto androidx.savedstate.SavedStateContainer and introducing nested handles.

What SavedStateHandle adds

The change in lifecycle-viewmodel-savedstate adds three @MainThread functions to SavedStateHandle:

SavedStateHandle.kt
@MainThread public fun asContainer(): SavedStateContainer
@MainThread public fun createOrGetContainer(key: String): SavedStateContainer
@MainThread public fun createOrGetSavedStateHandle(key: String): SavedStateHandle

asContainer() exposes the SavedStateContainer backing the current handle. It also replaces the old @RestrictTo(LIBRARY_GROUP) savedStateProvider() method that Lifecycle used internally when bundling ViewModel state.

createOrGetContainer(key) returns an existing nested SavedStateContainer registered under key, or creates and registers a new child container on first access. createOrGetSavedStateHandle(key) wraps that child container in a new SavedStateHandle.

One detail is worth noting if you read the commit message alongside the source: the draft RelNote mentions getSavedStateHandle(), while the checked-in public API in SavedStateHandle.kt and api/current.txt is createOrGetSavedStateHandle(key: String). Because these details come from an upstream development commit, method names and behavior can still change before a stable release.

Child state holders without key prefixes

With a flat SavedStateHandle, a reusable form holder had to carry a caller-supplied prefix just to stay out of its neighbor’s way:

PrefixedAddressFormStateHolder.kt
class PrefixedAddressFormStateHolder(
private val savedStateHandle: SavedStateHandle,
prefix: String,
) {
private val streetKey = "${prefix}_street"
private val cityKey = "${prefix}_city"
val street: StateFlow<String> = savedStateHandle.getStateFlow(streetKey, "")
val city: StateFlow<String> = savedStateHandle.getStateFlow(cityKey, "")
fun onStreetChanged(value: String) {
savedStateHandle[streetKey] = value
}
fun onCityChanged(value: String) {
savedStateHandle[cityKey] = value
}
}

With createOrGetSavedStateHandle, the child holder owns an isolated namespace. Plain keys are safe again:

AddressFormStateHolder.kt
class AddressFormStateHolder(
private val savedStateHandle: SavedStateHandle,
) {
val street: StateFlow<String> = savedStateHandle.getStateFlow("street", "")
val city: StateFlow<String> = savedStateHandle.getStateFlow("city", "")
fun onStreetChanged(value: String) {
savedStateHandle["street"] = value
}
fun onCityChanged(value: String) {
savedStateHandle["city"] = value
}
}

The parent ViewModel decides how each child is scoped in its saved state tree:

CheckoutViewModel.kt
class CheckoutViewModel(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
val shippingAddress = AddressFormStateHolder(
savedStateHandle.createOrGetSavedStateHandle("shipping_address")
)
val billingAddress = AddressFormStateHolder(
savedStateHandle.createOrGetSavedStateHandle("billing_address")
)
}

Both forms use the exact same class and key names without colliding. When Android saves the activity or navigation entry state, the parent container serializes "shipping_address" and "billing_address" as nested SavedState bundles. Calling createOrGetSavedStateHandle("shipping_address") a second time attaches to the same underlying child SavedStateContainer, so both handles read and write the same values.

All three methods are annotated with @MainThread, matching the rest of SavedStateHandle. Create child handles during ViewModel initialization on the main thread or from Dispatchers.Main.immediate.

Passing containers to non-ViewModel components

Some state holders avoid depending on lifecycle-viewmodel-savedstate so they can stay usable outside ViewModels or in lower-level multiplatform modules that only pull in androidx.savedstate. createOrGetContainer and asContainer bridge that boundary without custom SavedStateProvider glue:

CatalogViewModel.kt
class CatalogFilterController(
private val container: SavedStateContainer,
) {
// Manages filter state directly through androidx.savedstate APIs
}
class CatalogViewModel(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
// Isolated sub-scope under "catalog_filters"
val filters = CatalogFilterController(
savedStateHandle.createOrGetContainer("catalog_filters")
)
// Shares the ViewModel's root container when isolation isn't needed
val rootContainer: SavedStateContainer = savedStateHandle.asContainer()
}

Containers can nest further too. The upstream tests verify calling childContainer.createOrGetContainer("subNested"), so a feature controller handed a SavedStateContainer can carve out its own child scopes without ever touching a ViewModel or SavedStateHandle.

Why getStateFlow and getLiveData now conflict

This migration includes one deliberate breaking behavior change: calling getStateFlow and getLiveData with the same key now throws an IllegalArgumentException.

Previously, getMutableStateFlow and getLiveData were already mutually exclusive on the same key, while read-only getStateFlow(key, initialValue) lived in a separate flows map. Because of that extra map, you could observe a key through getStateFlow and still mutate it through getLiveData (updating the LiveData pushed the new value into flows[key] as well).

That special case is gone. getStateFlow(key, initialValue) now delegates directly to getMutableStateFlow(key, initialValue).asStateFlow(). Accessing the same key through both APIs fails immediately, regardless of call order. SavedStateHandleTest.kt.

Codebases partway through a LiveData-to-StateFlow migration are the most likely place to trip over this:

SearchViewModel.kt
class SearchViewModel(
savedStateHandle: SavedStateHandle,
) : ViewModel() {
// Broken after this change: throws IllegalArgumentException for "query"
// val queryLiveData = savedStateHandle.getLiveData("query", "")
// val queryFlow = savedStateHandle.getStateFlow("query", "")
// Fix: keep one saved backing source and derive the second stream
private val mutableQuery: MutableStateFlow<String> =
savedStateHandle.getMutableStateFlow("query", "")
val queryFlow: StateFlow<String> = mutableQuery.asStateFlow()
val queryLiveData: LiveData<String> = mutableQuery.asLiveData()
fun onQueryChanged(newQuery: String) {
mutableQuery.value = newQuery
}
}

Pick one saved state representation per key. If an older XML fragment or observer still expects LiveData, expose .asLiveData() from the StateFlow instead of registering two saved wrappers on "query".

What changed inside SavedStateHandleImpl

Looking inside SavedStateHandleImpl.kt and SavedStateHandle.android.kt makes both the new nesting APIs and the LiveData restriction easy to understand.

Before this commit, SavedStateHandle maintained five separate maps across common and Android source sets: regular, providers, flows, mutableFlows, and liveDatas (backed by a custom SavingStateLiveData subclass). Every set(), remove(), and state-saving pass had to synchronize those maps by hand.

Now SavedStateHandleImpl wraps a single SavedStateContainer. Every entry in that container is stored as an internal SavedStateValue<T> implementation, bundled under the "androidx.lifecycle.savedstate.value" key when saved:

  • SimpleSavedStateValue<T> holds plain handle["key"] = value entries.
  • StateFlowSavedStateValue<T> holds a MutableStateFlow<T> for both getStateFlow and getMutableStateFlow.
  • LiveDataSavedStateValue<T> (Android-only) wraps MutableLiveData<T>, replacing SavingStateLiveData.
  • SavedStateProviderSavedStateValue wraps registered SavedStateProvider callbacks.

One key maps to one SavedStateValue slot in the container. There is nowhere left for a parallel LiveData and StateFlow pair to live under the same key.

Two edge cases in the diff show how backwards compatibility was preserved during this rewrite.

Right after process recreation, SavedStateHandle.createHandle populates the root container before your ViewModel constructor runs. At that moment, Lifecycle cannot tell whether "shipping_address" was a plain Bundle value or a child container, so the restored entry initially lands in the parent container as a SimpleSavedStateValue holding a SavedState object. When your code later calls createOrGetContainer("shipping_address") (directly or through createOrGetSavedStateHandle), SavedStateHandleImpl spots that SimpleSavedStateValue, removes the placeholder, creates the real child SavedStateContainer, and unpacks the restored key-value pairs into it. Even if you create a child state holder lazily when the user expands an optional form section, its restored state is still there.

contains(key) also needed a guard on Android for uninitialized LiveData. Calling handle.getLiveData<String>("draft") with no initial value registers a LiveDataSavedStateValue in the container right away. Checking key in impl alone would have made "draft" in handle return true before any value was set. SavedStateHandle.android.kt checks savedStateValue.value.isInitialized whenever the stored wrapper is LiveDataSavedStateValue, keeping contains() consistent with earlier releases.

Practical takeaways before adopting it

When this change ships in an upcoming lifecycle-viewmodel-savedstate alpha, audit existing ViewModels for any string key shared between getLiveData and getStateFlow before bumping the version. The compiler cannot catch duplicate string keys, and the failure only triggers at runtime when the second accessor runs.

For new screens, createOrGetSavedStateHandle fits best when one ViewModel composes repeated form sections, multi-step wizard pages, or shared filter controllers that each persist a few fields across process death. A simple screen with two or three flat keys does not need nested handles just because the API exists. I inspected upstream commit 8f4994031e712166d1df58a75f9dae2669424e95 and its device tests; the application snippets here were written against those signatures and were not compiled against a published artifact.

SHARE ENTRYLinkedIn ↗X ↗Markdown / AI ↗
Discussions ardakazanci/blog-comments
● GiscusView discussions on GitHub ↗
⑂Reactions & comments/ Sign in with GitHub to react or join the discussion.
Notifications Newsletter · beehiiv
✓ Active channelSubscribe directly ↗
✉Get new posts by email/ Android, Kotlin, and Compose notes. Straight to your inbox.

SUBSCRIPTION CONFIG · EMAIL DISPATCH

Stay synced with new entries

Receive practical engineering notes on Android architecture, Kotlin, and Jetpack Compose whenever a new article is published.

channel = "email"spam = falseunsubscribe = "anytime"
K subscriber.propertiesEmail address