How Compose Remembers: The Positional Memoization Behind remember and State

skydovesJaewoong Eum (skydoves)||14 min read

How Compose Remembers: The Positional Memoization Behind remember and State

Every Compose developer has written remember { mutableStateOf(0) }. The value survives recomposition without any explicit storage reference. No ViewModel, no map, no key. Compose knows where the value belongs based on where the remember call appears in the source code. This mechanism is called positional memoization: values are identified not by name, but by their position in the execution trace of the composition.

In this article, you'll dive deep into the positional memoization system that powers remember, exploring how the compiler transforms remember calls into Composer.cache invocations, how cache reads from and writes to the slot table using a sequential cursor, how the changed() function advances through stored keys to detect invalidation, why or is used instead of || when combining key checks, how RememberObserver values receive lifecycle callbacks, and how the skipping property determines whether the runtime re executes a group or reuses stored data.

The fundamental problem: State without storage references

Consider a simple counter composable:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

Where does count live? There's no field in a class, no entry in a map, no unique identifier passed to remember. If you call Counter() from two different places, each call gets its own independent count. The runtime distinguishes them purely by position: the first Counter call occupies one position in the composition tree, the second call occupies another.

Think of the slot table as a filing cabinet with numbered drawers. Each time composition runs, the runtime opens drawers in the same order: drawer 0, drawer 1, drawer 2, and so on. As long as your composable functions execute in the same order, each remember call opens the same drawer it opened last time and finds the same value waiting inside.

The remember API: Surface and overloads

The simplest remember overload takes no keys. It calls currentComposer.cache(false, calculation), passing false to indicate the cached value is never invalidated by key changes:

@Composable
inline fun <T> remember(
    crossinline calculation: @DisallowComposableCalls () -> T
): T = currentComposer.cache(false, calculation)

The single key variant passes the result of currentComposer.changed(key1) as the invalid flag. If the key changed since the last composition, the cached value is recalculated:

@Composable
inline fun <T> remember(
    key1: Any?,
    crossinline calculation: @DisallowComposableCalls () -> T,
): T {
    return currentComposer.cache(
        currentComposer.changed(key1), calculation
    )
}

The two key variant combines checks with or:

@Composable
inline fun <T> remember(
    key1: Any?,
    key2: Any?,
    crossinline calculation: @DisallowComposableCalls () -> T,
): T {
    return currentComposer.cache(
        currentComposer.changed(key1) or
            currentComposer.changed(key2),
        calculation,
    )
}

Notice the use of or instead of ||. This is not a stylistic choice. Each call to changed() reads the next slot from the slot table and advances the reader cursor forward. If || were used and the first changed() returned true, the second changed() would be short circuited and never execute. The cursor would not advance past the second key's slot, and every subsequent slot read in the composable would be off by one. The non short circuiting or ensures every changed() call executes, keeping the cursor synchronized.

The vararg overload follows the same pattern, iterating over all keys with or:

@Composable
inline fun <T> remember(
    vararg keys: Any?,
    crossinline calculation: @DisallowComposableCalls () -> T,
): T {
    var invalid = false
    for (key in keys) invalid = invalid or currentComposer.changed(key)
    return currentComposer.cache(invalid, calculation)
}

The @DisallowComposableCalls annotation on the lambda prevents composable function calls inside the remember block. This is enforced because remember's lambda executes outside the normal composition flow, during cache evaluation, where the Composer is not prepared for nested composable calls.

The compiler transform: From remember to Composer.cache

When you write a composable function that uses remember, the Compose compiler plugin transforms it. Consider this source code:

@Composable
fun Greeting(name: String) {
    val message = remember { "Hello, $name" }
    Text(message)
}

After transformation, the function receives additional parameters and is wrapped in group bookkeeping. The transformed output looks approximately like this (simplified):

fun Greeting(name: String, $composer: Composer, $changed: Int) {
    $composer.startRestartGroup(0x7a3d2f1e)

    val message = $composer.cache(false) { "Hello, $name" }
    Text(message, $composer, 0)

    $composer.endRestartGroup()?.updateScope { c, _ ->
        Greeting(name, c, $changed or 0b1)
    }
}

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor