---
title: "Nested SavedStateHandles: Isolating Child State Without Key Prefixes"
description: "Isolate reusable state holders inside a single ViewModel with nested SavedStateHandles, SavedStateContainer integration, and the new StateFlow-LiveData rules."
pubDatetime: 2026-11-01
author: "Arda Kazancı"
canonicalUrl: "https://ardakazanci.com/posts/savedstatehandle-savedstatecontainer/"
tags: ["android", "kotlin", "architecture"]
---
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`](https://github.com/androidx/androidx/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`:

```kotlin title="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`](https://github.com/androidx/androidx/blob/8f4994031e712166d1df58a75f9dae2669424e95/lifecycle/lifecycle-viewmodel-savedstate/src/commonMain/kotlin/androidx/lifecycle/SavedStateHandle.kt) and [`api/current.txt`](https://github.com/androidx/androidx/blob/8f4994031e712166d1df58a75f9dae2669424e95/lifecycle/lifecycle-viewmodel-savedstate/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:

```kotlin title="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:

```kotlin title="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:

```kotlin title="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:

```kotlin title="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](https://github.com/androidx/androidx/blob/8f4994031e712166d1df58a75f9dae2669424e95/lifecycle/lifecycle-viewmodel-savedstate/src/androidDeviceTest/kotlin/androidx/lifecycle/viewmodel/savedstate/SavedStateHandleTest.kt).

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

```kotlin title="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`](https://github.com/androidx/androidx/blob/8f4994031e712166d1df58a75f9dae2669424e95/lifecycle/lifecycle-viewmodel-savedstate/src/commonMain/kotlin/androidx/lifecycle/internal/SavedStateHandleImpl.kt) and [`SavedStateHandle.android.kt`](https://github.com/androidx/androidx/blob/8f4994031e712166d1df58a75f9dae2669424e95/lifecycle/lifecycle-viewmodel-savedstate/src/androidMain/kotlin/androidx/lifecycle/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.
