Saved State: rememberSaveable vs SavedStateHandle
Both survive process death, so teams pick by habit. The real axis is ownership and timing: SavedStateHandle is readable before the ViewModel's init runs and can start the right load; rememberSaveable arrives a frame later and only the composable can see it.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Pick by asking what losing the value costs. Annoys the user (expanded, scrolled, which
tab, typed text) → rememberSaveable. Restarts or misdirects work (the query that decides what you
fetch, a wizard step, a selected id) → SavedStateHandle, because it is populated before the
ViewModel's init runs and can drive the first load. Both write into the same size-limited saved
instance state Bundle, so store ids, not objects. And a plain MutableStateFlow in a ViewModel
survives rotation but not process death — the failure that never reproduces on your desk.
They solve the same problem at different altitudes
Both APIs write into the platform's saved instance state — the Bundle the system hands back when your
process was killed in the background and the user returned. Config changes are the easy half; process
death is the one that matters, and it isn't an edge
case.
What differs is who owns the value and when it becomes readable.
// Owned by the composable. Readable on first composition.
var expanded by rememberSaveable { mutableStateOf(false) }
// Owned by the ViewModel. Readable in init, before the first frame.
class SearchViewModel(private val handle: SavedStateHandle) : ViewModel() {
val query = handle.getStateFlow("query", "")
}
The timing difference is the whole decision
This is the part that isn't in most comparisons, and it's the one that decides real cases.
SavedStateHandle is injected into the ViewModel's constructor, already populated. So restored state
can decide what the ViewModel does first:
class SearchViewModel(handle: SavedStateHandle) : ViewModel() {
private val query = handle.getStateFlow("query", "")
// On process-death restore this starts with "kotlin coroutines",
// not "" — no empty-state flash, no wasted default request.
val results = query
.debounce(300)
.flatMapLatest { repo.search(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
Try that with rememberSaveable and the sequence is: ViewModel constructed with an empty query →
kicks off the default load → first composition → restored query arrives → second load. The user sees a
flash of the wrong screen and you paid for a request nobody wanted.
Rule of thumb: if a value is an input to a data load, it belongs in SavedStateHandle.
What rememberSaveable is genuinely better at
It's not the lesser API — it's the right one for state that has no consumer outside the composable:
@Composable
fun FaqItem(item: Faq) {
var expanded by rememberSaveable { mutableStateOf(false) }
// ...
}
Putting that in a ViewModel means a map of id -> Boolean, a key, an event, and a test — to record
whether a row is open. It's presentation state; keep it in the presentation layer. The same goes for
scroll position (rememberLazyListState is already saveable), the selected tab of a purely visual
pager, and a TextFieldState whose content isn't a query.
It also scopes correctly for free: navigate away for good and the state is discarded, which is usually what you want and is manual work in a ViewModel.
The Bundle is not storage
Both APIs land in the same place, and that place is small and shared. The binder transaction limit is roughly 1MB for everything being saved, and you are not the only contributor.
// Wrong. Works on your device with 12 test items.
var cart by rememberSaveable { mutableStateOf<List<CartItem>>(emptyList()) }
// Right. Save the identity, refetch the data.
var cartIds by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
TransactionTooLargeException is a crash you'll see in Crashlytics from a user with a big cart, on a
device you don't have, and it will make no sense until you remember this. The heuristic: save what
identifies the state, never the state itself. Ids, keys, indices, a query string — not lists,
bitmaps, or fully hydrated domain objects.
Custom types
rememberSaveable handles primitives, String, and anything Parcelable. For everything else, a
Saver — and listSaver/mapSaver cover most cases in five lines:
data class DateRange(val from: LocalDate, val to: LocalDate)
val DateRangeSaver = listSaver<DateRange, String>(
save = { listOf(it.from.toString(), it.to.toString()) },
restore = { DateRange(LocalDate.parse(it[0]), LocalDate.parse(it[1])) }
)
// Plain value:
val range = rememberSaveable(saver = DateRangeSaver) { DateRange(today, today) }
// Wrapped in a MutableState — note stateSaver, not saver:
var range by rememberSaveable(stateSaver = DateRangeSaver) {
mutableStateOf(DateRange(today, today))
}
SavedStateHandle has the same constraint — it stores Bundle-compatible types — and with type-safe
Navigation your route arguments arrive through it automatically (handle.toRoute<SearchRoute>()),
which is another reason ViewModel-level state tends to concentrate there.
Two failure modes worth knowing by name
Positional keys in a lazy list. rememberSaveable keys state by its position in the composition. In
a LazyColumn without stable item keys, saved state reattaches to whatever row lands in that slot after
a restore:
LazyColumn {
items(faqs, key = { it.id }) { faq -> // ← the key is not optional
FaqItem(faq)
}
}
The ViewModel field that only half-survives. A MutableStateFlow in a ViewModel survives rotation,
because the ViewModel does. It does not survive process death. This is the most common saved-state bug
in Android and the hardest to notice, because rotation is what everyone tests. Reproduce it properly:
adb shell am kill com.example.app # background the app first
Not "Don't keep activities" — that only covers config changes. Kill the process while backgrounded and return via recents. That's the real path.
Testing it
Both are testable, cheaply, and there's no excuse for shipping this untested:
// Compose: drives a real save/restore cycle
val restorationTester = StateRestorationTester(composeTestRule)
restorationTester.setContent { FaqItem(faq) }
composeTestRule.onNodeWithText("Show more").performClick()
restorationTester.emulateSavedInstanceStateRestore()
composeTestRule.onNodeWithText("Hide").assertExists()
// ViewModel: just construct the handle with the restored values
val vm = SearchViewModel(SavedStateHandle(mapOf("query" to "kotlin coroutines")))
assertEquals("kotlin coroutines", vm.query.value)
The second one takes four lines and covers the case that actually reaches production.
The rule
rememberSaveable is for state the UI owns. SavedStateHandle is for state that decides what to
load. When it's genuinely ambiguous, put it in SavedStateHandle — the cost is a key and a line of
code, and the benefit is that a restored user lands on the screen they left instead of the screen your
defaults describe.