rememberSaveable survives what remember doesn't — and it isn't a bigger remember

remember dies on rotation. rememberSaveable survives rotation and process death by writing into the saved instance state Bundle — which has limits, costs, and a custom-type story worth knowing before you need it.

6 min read
androidcomposekotlinstate

Day 14 — Three ways state dies

Day 14 of 100. Yesterday ended on a three-level hierarchy: composition, ViewModel, saved state. Today is the third level — what it actually stores, what it costs, and the one API you need when your state isn't a String.

The symptom

A multi-step form. The user gets to step three, takes a call, comes back, and is on step one.

@Composable
fun Wizard() {
    var step by remember { mutableStateOf(0) }
    var answers by remember { mutableStateOf(emptyMap<String, String>()) }
    …
}

Rotation loses it. Backgrounding the app for long enough loses it. And it will look fine in testing, because a phone you're actively holding rarely has its process killed — which is exactly why this reaches production.

Why the obvious fix fails

The obvious fix is to swap in rememberSaveable everywhere:

var step by rememberSaveable { mutableStateOf(0) }
var answers by rememberSaveable { mutableStateOf(emptyMap<String, String>()) }   // crashes

The first line works. The second throws at runtime: "MutableState containing Map was not able to be saved using the current SaveableStateRegistry."

rememberSaveable only handles what a Bundle can hold — primitives, String, Parcelable, Serializable, and arrays and lists of those. A Map is not on the list, and neither is your domain model.

The second failure is quieter. Saving everything you can save is a real cost: the bundle is written on the main thread during onSaveInstanceState, and it has a hard size limit — exceed roughly 1 MB across the transaction and the system throws TransactionTooLargeException and kills the process. Saving a list of results "just in case" is how apps crash on rotation only in production, only for users with a lot of data.

Three ways state dies: recomposition, configuration change, process death — and what survives each

The actual mechanism

Three distinct death events, and each tool survives a different set:

recomposition config change process death
plain var
remember
rememberSaveable
ViewModel
ViewModel + SavedStateHandle
database / DataStore ✓ (and app restart)

rememberSaveable writes into the same saved-instance-state bundle the View system has always used. Compose hooks it via SaveableStateRegistry, keyed — like remember — by call site, which is why the identity rules from Day 8 apply here too. Put a rememberSaveable inside an unkeyed list and restoration attaches values to the wrong rows.

Because it's the Android bundle, its limits are Android's limits. Nothing about the Compose API changes what a Bundle can carry or how big it may get.

Saving a type the Bundle doesn't know

Two ways to make a custom type saveable, and the choice is mostly about whether you want the annotation dependency.

@Parcelize — the simplest option for your own classes:

@Parcelize
data class WizardAnswers(val values: Map<String, String>) : Parcelable

var answers by rememberSaveable { mutableStateOf(WizardAnswers(emptyMap())) }

A custom Saver — for types you don't own, or when you want to store less than the whole object:

val WizardSaver = listSaver<WizardState, Any>(
    save = { listOf(it.step, it.answers.keys.toList(), it.answers.values.toList()) },
    restore = {
        @Suppress("UNCHECKED_CAST")
        WizardState(
            step = it[0] as Int,
            answers = (it[1] as List<String>).zip(it[2] as List<String>).toMap(),
        )
    },
)

val state = rememberSaveable(saver = WizardSaver) { WizardState() }

mapSaver is the same idea with named keys, which reads better when there are more than three fields. The genuinely useful property of a hand-written Saver is that save can be lossy on purpose — store the selected item's id rather than the item, and let restore look it up. That's usually what you want anyway: the id is stable, the object may be stale.

Where it belongs, and where it doesn't

rememberSaveable is for UI element state that would annoy someone if it reset: scroll position, which tab is selected, an expanded section, a partially typed field, the current wizard step. Small, transient, cheap to serialise.

It is not the place for the screen's data. A list of orders belongs in a ViewModel backed by a repository — re-fetch or read from disk, don't carry it through a bundle. The heuristic that keeps it honest: if it came from the network or a database, don't save it; re-derive it.

That's also why the built-in rememberScrollState, rememberLazyListState and rememberPagerState are all saveable by default. They're exactly this category, and the library did the work so you don't hand-roll a Saver for a scroll offset.

A useful sanity check when you're unsure: ask what the worst outcome is if the value is lost. If it's "the user scrolls down again" — annoying, save it. If it's "the app shows a spinner for 200ms" — fine, don't. If it's "the user retypes a paragraph" — that's not UI state at all, and it should have been written somewhere durable the moment they typed it.

Navigation is the other saver you're already using

Arguments passed to a navigation destination survive process death too, because the back stack is itself saved instance state. That makes route arguments a legitimate place for small identifying values:

composable("order/{orderId}") { backStackEntry ->
    OrderScreen(orderId = backStackEntry.arguments!!.getString("orderId")!!)
}

The id survives; the order object is re-fetched. This is the id-not-the-object rule from the Saver section, enforced by the navigation API rather than by discipline — which is why routes should carry ids and never serialised models, whatever the encoding makes technically possible.

The one that surprises everyone

rememberSaveable restores before any effect runs, which means state you compute in a LaunchedEffect will overwrite the restored value if you're not careful:

var selectedId by rememberSaveable { mutableStateOf<String?>(null) }

LaunchedEffect(items) {
    selectedId = items.firstOrNull()?.id      // clobbers the restored selection
}

The fix is to make the effect respect existing state — if (selectedId == null) — or, better, to model the default as a fallback at read time rather than a write:

val effectiveId = selectedId ?: items.firstOrNull()?.id

The second version has no ordering to get wrong, which is the general shape of the fix: a derived read beats a written default every time.

How to prove it

Rotation only tests half of it. Process death needs to be triggered deliberately:

adb shell am kill com.example.app     # kills the process, keeps the task

Then reopen from Recents. Anything held only in a ViewModel is gone; anything in rememberSaveable or SavedStateHandle comes back. "Don't keep activities" in Developer Options is the weaker version — it tests configuration change, not process death, and passing it proves less than people assume.

For the size limit, Bundle.getSize() in a debug build, or just watching for TransactionTooLargeException under a large dataset, tells you whether you're near the edge. It is not a limit you want to discover from Play Console.

What this generalizes to

The general lesson is that "persistent" is not one thing. There's a hierarchy — in-memory-per-frame, in-memory-across-config, serialised-across-process, on-disk-across-install — and each level costs more than the one below it in serialisation, size limits, or I/O.

The mistake isn't picking the wrong level once; it's not knowing the hierarchy exists and defaulting to whichever tool you learned first. Compose makes the levels explicit in the API names, which is unusually honest, and it's why "just use rememberSaveable everywhere" is as wrong as never using it.

Tomorrow, Day 15: CompositionLocal — passing values down the tree without parameters, and why it's a much narrower tool than it first appears.


Day 14 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Save UI state in Compose.