remember is not a cache, and the key is the whole contract
It is scoped to a composition and keyed by its arguments. A missing key gives stale state; a wrong one recomputes every frame.
TL;DR
remember stores a value in the composition's slot table, at the position where the call
appears, keyed by the arguments you pass it. It is not a cache keyed by content and it has no
eviction policy — its lifetime is the lifetime of that call site in that composition.
Two failure modes follow. No key means the value is computed once and never recomputed, so it goes
stale when its inputs change. A key that is a new object on every recomposition means the value is
recomputed every time, which is the expensive version of not using remember at all.
The key is the contract: it declares exactly when the value should be rebuilt.
The case this post is about
A filtered, sorted list derived from two inputs:
@Composable
fun ItemList(items: List<Item>, query: String, sort: SortOrder) {
val visible = remember { // no key
items.filter { it.title.contains(query, ignoreCase = true) }
.sortedWith(sort.comparator)
}
LazyColumn { items(visible, key = { it.id }) { ItemRow(it) } }
}
And a formatter alongside it:
val formatter = remember { DateTimeFormatter.ofPattern("d MMM", locale) }
Both compiled. Both looked like sensible memoisation. One never updated and the other was fine until the locale changed.
The symptom
The list never filtered. Typing in the search box changed query and the list did not move:
query = "" → 60 items
query = "kot" → 60 items (expected 4)
query = "xyzzy" → 60 items
Then a different screen had the opposite problem. After someone "fixed" a similar block by adding a key, scrolling became janky:
val visible = remember(items.filter { it.isActive }) { … } // ← a NEW list every recomposition
scroll 60 items
frame P95 84ms recomputation on every frame
And a third variant produced a crash after rotation:
val expanded = remember { mutableStateOf(false) } // lost on rotation, which was fine
val pager = remember { Pager(config, source) } // recreated, mid-flight requests orphaned
Three bugs: stale, over-eager, and wrong-lifetime. All three are the key.
Why the obvious fix failed
"Add the inputs as keys." The right answer, and the first attempt passed a computed value as
the key — remember(items.filter { … }) — which allocates a fresh list each recomposition, so the
key never equals its predecessor. The lambda runs every time, plus you now pay for the filter
twice.
"Use derivedStateOf instead." The right tool for a value read during composition from
State sources, and it is not a drop-in replacement: it needs its inputs to be State, and it
changes when invalidation propagates rather than caching across recompositions. Reaching for it
without understanding the distinction produced a version that was correct and no faster.
"Use rememberSaveable." Fixes the rotation case for savable types and does nothing for the
staleness, and a Pager is not savable anyway.
"Hoist the computation into the ViewModel." Correct for this case, and worth saying why: the
filtered list is derived state that does not depend on composition at all. That realisation is the
real fix — but it does not teach you what remember does, and the next genuinely
composition-scoped value will hit the same wall.
The actual mechanism
remember has three properties, and each failure maps to one.
It is positional. The value is stored in the slot table at the position of the call in the
composition tree. Two remember calls in the same composable occupy different slots; the same call
in two instances of a composable occupies two slots. There is no global namespace and no content
addressing.
It is keyed by equals on its arguments. On each recomposition, Compose compares the passed
keys to the stored ones. Equal → return the stored value. Not equal → run the lambda and store the
new result. No arguments means "no keys", which compares equal forever.
Its lifetime is the call site's presence in composition. Leave the composition — navigate away,
leave a LazyColumn's cache, rotate without rememberSaveable — and the slot is discarded. There
is no eviction policy because there is no cache; there is a tree, and the value lives as long as
that node does.
rememberSaveable extends the lifetime without changing any of the above. It is the natural
next reach — it survives rotation and process death — and it is still positional, still keyed by
equals, and still discarded when the call site leaves composition. What it adds is serialisation
through the platform's saved-state bundle, which brings two constraints of its own:
rememberSaveable { mutableStateOf(query) } // String — fine
rememberSaveable { mutableStateOf(searchResults) } // List<Result> — runtime failure
The second throws unless the type is Parcelable/Serializable or you supply a custom Saver.
And the saved-state bundle is small — a platform-level limit measured in hundreds of kilobytes
across the whole activity, not a per-key budget — so a list that happens to serialise cleanly today
is a crash when a user has two hundred results instead of ten.
So it widens the lifetime by one axis and inherits every other property, which is why it is not
the answer to "my expensive computation keeps re-running". That is a question about caching, and
the correct home for a cache is a component whose lifetime you chose deliberately — a ViewModel, a
repository, a Store — rather than the composition tree, whose lifetime is decided by navigation
and scrolling.

remember(k1, k2) { … }reads as: recompute this exactly whenk1ork2changes. The keys are not a hint or an optimisation — they are the declaration of when the value is invalid.
The fix
Key by the stable inputs, and move genuinely derived state out of composition.
The filter and sort belong to the ViewModel, because they depend on nothing about the UI:
// ViewModel — derived once per input change, not per composition
val visible: StateFlow<ImmutableList<Item>> =
combine(items, query, sort) { items, q, s ->
items.filter { it.title.contains(q, ignoreCase = true) }
.sortedWith(s.comparator)
.toImmutableList()
}.stateIn(viewModelScope, WhileSubscribed(5_000), persistentListOf())
@Composable
fun ItemList(vm: ItemListViewModel) {
val visible by vm.visible.collectAsStateWithLifecycle()
LazyColumn { items(visible, key = { it.id }) { ItemRow(it) } }
}
Where a value genuinely is composition-scoped, key it by stable inputs:
// correct: `locale` is a stable value, not a freshly computed object
val formatter = remember(locale) { DateTimeFormatter.ofPattern("d MMM", locale) }
// correct: an expensive derivation from parameters, keyed by those parameters
val layout = remember(width, itemCount) { computeGrid(width, itemCount) }
Three rules that cover almost every case:
Keys must be cheap to compare and stable. A String, an Int, a value class, a stable data
class. Never a freshly allocated collection or lambda — that is the over-eager failure.
No keys means "for the lifetime of this node, unconditionally". That is correct for a
mutableStateOf initial value, a CoroutineScope, or anything that should be created once per
screen entry. It is wrong for anything derived from a parameter.
derivedStateOf is for narrowing invalidation, not for caching across recompositions:
// correct use: a cheap computation over State, read in composition,
// where you want recomposition only when the RESULT changes
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
Without derivedStateOf, reading firstVisibleItemIndex directly recomposes on every scroll pixel.
With it, recomposition happens only when the boolean flips. That is the distinction: remember
caches across recompositions, derivedStateOf reduces how many recompositions happen.
And the right lifetime for the Pager:
// the Pager belongs to the ViewModel — it holds in-flight requests and must
// survive rotation, which composition does not.
class ItemListViewModel : ViewModel() {
val paged = Pager(config) { source }.flow.cachedIn(viewModelScope)
}
How we proved it
That the list now filters — the test the original code would have failed:
@Test
fun filtersAsTheQueryChanges() {
composeTestRule.setContent { ItemList(vm) }
composeTestRule.onAllNodesWithTag("row").assertCountEquals(60)
vm.onQueryChange("kot")
composeTestRule.waitForIdle()
composeTestRule.onAllNodesWithTag("row").assertCountEquals(4)
}
remember { } with no key: FAILED — expected 4, found 60
after: PASSED
That the computation runs the right number of times. This is the assertion that distinguishes all three bugs from each other:
@Test
fun derivationRunsOncePerInputChangeNotPerRecomposition() {
var runs = 0
composeTestRule.setContent {
var tick by remember { mutableStateOf(0) }
val v = remember(query) { runs++; derive(query) } // query fixed in this test
LaunchedEffect(Unit) { repeat(30) { tick++; awaitFrame() } }
Text("$v$tick")
}
composeTestRule.waitForIdle()
assertEquals(1, runs) // 30 recompositions, one derivation
}
remember(items.filter { … }) { … }: FAILED — runs = 31
remember(query) { … }: PASSED — runs = 1
And the frame timing on the over-eager version:
scroll 60 items
remember(freshly-computed-key) frame P95 84.1ms
remember(stable-key) frame P95 8.3ms
derived in ViewModel frame P95 7.9ms
One limit: the runs counter test uses a mutable var captured from a composable, which is fine
for counting in a test and is not something to ship. And the frame numbers are one device and one
list size — the useful comparison is the ratio between the three rows, not the absolute values.
What this generalizes to
A memoisation key is a declaration of invalidity. Not an optimisation hint — a statement of when the cached value stops being true. Getting it wrong in one direction gives stale data and in the other gives no caching, and both look like the code "has memoisation".
Never allocate a key. A key compared by equals must be something that can actually be equal to
its previous value. Freshly constructed collections, lambdas and objects are the standard way to
accidentally disable the mechanism you just added.
Match the lifetime to the owner. A value scoped to a composition dies with the composition. If it must survive rotation, process death, or navigation, the composition is the wrong owner — and no amount of keying changes that.
Derived state usually does not belong in the view layer. The filter and sort depended on nothing about the UI. When a memoised computation inside a component takes only parameters as input, it is often a sign the computation belongs above the component entirely.
Related
- A LazyColumn without keys loses your scroll position and your animations — the other keying mistake
- derivedStateOf exists because you were recomposing on every keystroke — narrowing invalidation, in detail
- Recomposition is not re-rendering, and that is why your list stutters — what recomposition actually costs
Keep reading
This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".