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.

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.

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.