derivedStateOf & rememberSaveable
This lesson closes out the State & Architecture section with two narrower, more surgical tools.
derivedStateOf stops Compose from doing more recomposition work than it needs to when
you compute one value from another. rememberSaveable solves a problem plain
remember and even ViewModel don't fully cover: surviving the process being
killed entirely, not just a configuration change.
The Problem derivedStateOf Solves
Suppose RecipeBox's search screen tracks a search query and shows how many recipes currently match it. A naive approach computes the filtered count directly in the composable body:
@Composable
fun RecipeSearchScreen(recipes: List<Recipe>) {
var query by remember { mutableStateOf("") }
// Recalculated on EVERY recomposition of this composable,
// even ones triggered by something unrelated to query or recipes
val matchCount = recipes.count { it.title.contains(query, ignoreCase = true) }
// ...
}
That's not wrong, exactly — but matchCount gets recalculated every single time this
composable recomposes for any reason, not just when query or
recipes actually changed. For a filter over a small list that's harmless, but the
pattern matters once the computation is heavier, or once other unrelated state in the same
composable changes frequently (an animation value, a scroll position) and would otherwise force a
wasted recalculation on every frame.
derivedStateOf: Compute From State, Recompose Only When the Result Changes
derivedStateOf {} wraps a calculation and produces a Compose State object
that only reports a change — and therefore only triggers recomposition of readers — when the
computed result itself actually differs from its last value, regardless of how often the
inputs are read or the surrounding composable recomposes:
@Composable
fun RecipeSearchScreen(recipes: List<Recipe>) {
var query by remember { mutableStateOf("") }
val matchCount by remember {
derivedStateOf {
recipes.count { it.title.contains(query, ignoreCase = true) }
}
}
Column {
TextField(value = query, onValueChange = { query = it }, label = { Text("Search recipes") })
Text("$matchCount matching recipes")
}
}
Note the derivedStateOf call is itself wrapped in remember — this is the
standard pairing: remember keeps the same derived-state object alive across
recompositions, while derivedStateOf is what decides whether reading it should actually
invalidate anything downstream. If query changes from "egg" to
"eggs" but the resulting matchCount happens to stay at, say,
3 both times, anything reading only matchCount — like the
Text above — will not recompose, even though query itself
changed and the composable containing it did recompose.
derivedStateOf earns its keep when the computation is non-trivial, or when it reads state that changes far more often than its own result does (as above — every keystroke, but the count only changes sometimes). For a cheap one-line computation over infrequently changing state, plain reads are simpler and the overhead of derivedStateOf isn't worth it.rememberSaveable: Surviving Process Death, Not Just Recomposition
remember survives recomposition. A ViewModel survives configuration
changes like rotation. Neither one survives the Android system killing the app's process entirely
to reclaim memory while it's in the background — something that happens routinely on real devices,
not just in theory. When the user returns to the app afterward, Android recreates the Activity from
scratch, and both plain remember state and a fresh ViewModel
instance start over from nothing, unless something saved the value into the instance-state
Bundle first.
rememberSaveable is a drop-in replacement for remember that automatically
saves its value into that Bundle and restores it — surviving both configuration changes
and process death — for any type that's already Parcelable, or any of the simple types
(String, Int, Boolean, and similar) that Android's saved-state mechanism understands natively:
@Composable
fun RecipeSearchScreen(recipes: List<Recipe>) {
var query by rememberSaveable { mutableStateOf("") }
val matchCount by remember {
derivedStateOf {
recipes.count { it.title.contains(query, ignoreCase = true) }
}
}
Column {
TextField(value = query, onValueChange = { query = it }, label = { Text("Search recipes") })
Text("$matchCount matching recipes")
}
}
The only change from the earlier example is remember becoming
rememberSaveable on the query line — everything else, including the
derivedStateOf block, stays exactly the same. Now if the user types "choc"
into the search box, rotates the phone, or the OS kills the app in the background while they switch
to another app for a while, query comes back as "choc" instead of resetting
to an empty string, without RecipeBox writing a single line of manual save/restore code.
remember | rememberSaveable | ViewModel | |
|---|---|---|---|
| Survives recomposition | Yes | Yes | Yes |
| Survives rotation | No | Yes | Yes |
| Survives process death | No | Yes | No, by itself |
| Good for | Purely transient UI state | Small bits of UI state worth restoring, like a search box | Screen-level state and business logic |
SavedStateHandle API — a ViewModel on its own does not survive process death for free. For a single simple value like a search query, rememberSaveable is usually the more direct tool.