Runtime Saveable: How Compose Preserves State Across Process Death

skydovesJaewoong Eum (skydoves)||18 min read

Runtime Saveable: How Compose Preserves State Across Process Death

Jetpack Compose introduced a declarative paradigm for Android UI, but declarative doesn't mean stateless. User interactions create state like scroll positions, text field contents, and expanded sections that must survive configuration changes and process death. While remember preserves state across recompositions, it's helpless against activity recreation. This is where the runtime saveable module enters: a sophisticated state persistence system that bridges Compose's reactive world with Android's saved instance state mechanism.

In this article, you'll dive deep into the internal mechanisms of Compose's saveable APIs, exploring how rememberSaveable tracks and restores state through composition position keys, how the Saver interface enables type safe serialization of arbitrary objects, how SaveableStateRegistry manages multiple providers and preserves registration order, how SaveableStateHolder enables navigation patterns by scoping state to screen keys, and how all these components coordinate to seamlessly preserve UI state. This isn't a guide on using rememberSaveable. It's an exploration of the runtime machinery that makes state persistence invisible to developers.

The fundamental problem: State that survives process death

Consider this simple Compose code:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

This works perfectly for recomposition. Click the button, count increments, UI updates. But rotate the device, and count resets to zero. The activity was destroyed and recreated, and remember only survives within a single composition lifecycle.

The traditional Android solution is onSaveInstanceState:

class CounterActivity : ComponentActivity() {
    private var count = 0

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putInt("count", count)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        count = savedInstanceState?.getInt("count") ?: 0
    }
}

But this approach doesn't compose well with Compose. The state lives in the Activity, not the composable. You need to manually thread state through your composition hierarchy. And if you have dozens of stateful composables, the boilerplate becomes unmanageable.

Compose's saveable APIs solve this elegantly by integrating saved instance state directly into the composition model. Each rememberSaveable call automatically participates in the save/restore cycle, keyed by its position in the composition tree.

The Saver interface: Type safe state serialization

At the heart of the saveable system is the Saver interface, which defines how to convert between your domain types and Bundle compatible representations.

The core abstraction

The Saver interface is elegantly minimal:

public interface Saver<Original, Saveable : Any> {
    public fun SaverScope.save(value: Original): Saveable?
    public fun restore(value: Saveable): Original?
}

Two methods handle the round trip:

  1. save(): Converts your type to something Bundle compatible. Returning null means "don't save this value."
  2. restore(): Converts back to your original type. Returning null means "use the init lambda instead."

The SaverScope receiver on save() provides access to canBeSaved(value: Any): Boolean, allowing savers to validate nested values before attempting serialization.

The factory function

For convenience, a factory function creates Saver implementations from lambdas:

public fun <Original, Saveable : Any> Saver(
    save: SaverScope.(value: Original) -> Saveable?,
    restore: (value: Saveable) -> Original?,
): Saver<Original, Saveable> {
    return object : Saver<Original, Saveable> {
        override fun SaverScope.save(value: Original) = save.invoke(this, value)
        override fun restore(value: Saveable) = restore.invoke(value)
    }
}

This enables concise saver definitions:

val UserSaver = Saver<User, Bundle>(
    save = { user ->
        bundleOf("id" to user.id, "name" to user.name)
    },
    restore = { bundle ->
        User(bundle.getLong("id"), bundle.getString("name")!!)
    }
)

AutoSaver: The default implementation

When you call rememberSaveable without specifying a saver, it uses autoSaver():

public fun <T> autoSaver(): Saver<T, Any> =
    @Suppress("UNCHECKED_CAST") (AutoSaver as Saver<T, Any>)

private val AutoSaver = Saver<Any?, Any>(save = { it }, restore = { it })

The auto saver performs no conversion. It passes values through directly. This works for types already supported by Bundle: primitives, strings, parcelables, and serializable objects. For custom types, you need a custom saver.

listSaver: Decomposing into ordered values

The listSaver helper converts objects to lists of saveable values:

public fun <Original, Saveable> listSaver(
    save: SaverScope.(value: Original) -> List<Saveable>,
    restore: (list: List<Saveable>) -> Original?,
): Saver<Original, Any> =
    Saver(
        save = {
            val list = save(it)
            for (index in list.indices) {
                val item = list[index]
                if (item != null) {
                    require(canBeSaved(item)) { "item at index $index can't be saved: $item" }
                }
            }
            if (list.isNotEmpty()) ArrayList(list) else null
        },
        restore = restore as (Any) -> Original?,
    )

The implementation validates each list item can be saved, then wraps in ArrayList for Bundle compatibility. Empty lists return null to optimize storage.

mapSaver: Key value serialization

The mapSaver builds on listSaver with a clever encoding:

public fun <T> mapSaver(
    save: SaverScope.(value: T) -> Map<String, Any?>,
    restore: (Map<String, Any?>) -> T?,
): Saver<T, Any> =
    listSaver<T, Any?>(
        save = {
            mutableListOf<Any?>().apply {
                save(it).forEach { entry ->
                    add(entry.key)
                    add(entry.value)
                }
            }
        },
        restore = { list ->
            val map = mutableMapOf<String, Any?>()
            check(list.size.rem(2) == 0) { "non-zero remainder" }
            var index = 0
            while (index < list.size) {
                val key = list[index] as String
                val value = list[index + 1]
                map[key] = value
                index += 2
            }
            restore(map)
        },
    )

Rather than serializing a Map object (which has overhead), mapSaver flattens to a list with alternating keys and values: [key1, value1, key2, value2, ...]. This is more space efficient and avoids HashMap serialization complexity. The restore function rebuilds the map by iterating pairs.

SaveableStateRegistry: The state coordination center

The SaveableStateRegistry is the central hub that coordinates state saving and restoration across an entire composition subtree.

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