Compose Identity Mechanisms: How key() Transforms Into Movable Groups

skydovesJaewoong Eum (skydoves)||13 min read

Compose Identity Mechanisms: How key() Transforms Into Movable Groups

Jetpack Compose manages UI state through a sophisticated identity system that determines when composables should be reused versus recreated. When you wrap content in key(userId) { UserCard(user) }, you're providing Compose with identity information that survives recomposition, reordering, and structural changes. While most developers understand that key() helps preserve state when list items move, the deeper question remains: how does Compose actually track identity, and what happens at the compiler and runtime level when you use key()?

In this article, you'll dive deep into Compose's identity mechanisms, exploring how the compiler transforms key() calls into movable group instructions, how the runtime distinguishes between replaceable, movable, and restart groups, how the two level identity system combines source location keys with object keys, how JoinedKey combines multiple keys with special enum handling, and how the slot table stores and retrieves identity information during recomposition. This isn't a guide on using key(). It's an exploration of the compiler and runtime machinery that makes stable identity possible.

The fundamental problem: Positional identity breaks with structural changes

Consider a simple list that can be reordered:

@Composable
fun UserList(users: List<User>) {
    Column {
        for (user in users) {
            UserCard(user)
        }
    }
}

@Composable
fun UserCard(user: User) {
    var expanded by remember { mutableStateOf(false) }
    // ...
}

When users is [Alice, Bob, Charlie] and Alice's card is expanded, Compose remembers the expanded state. But what happens when the list becomes [Bob, Alice, Charlie]? Without explicit identity, Compose uses positional memoization: the first UserCard call maps to position 0, the second to position 1, and so on. When the list reorders, position 0 now contains Bob, but the expanded = true state from position 0 is still there. Bob's card incorrectly appears expanded.

The naive solution is recreating all state on every structural change, but this destroys the user experience. Scroll positions reset, animations restart, and text field contents vanish. Compose needs a way to track identity that survives positional changes.

The key() composable solves this by providing explicit identity:

for (user in users) {
    key(user.id) {
        UserCard(user)
    }
}

Now Compose tracks each UserCard by its user.id, not its position. When the list reorders, Alice's expanded state follows Alice. But how does this actually work? The answer lies in the compiler transformation and the group system.

Group architecture: The three types of composition units

Compose organizes the composition tree into groups, each serving a different purpose. The Composer interface defines three group types that the compiler emits based on code structure.

Replaceable groups

Replaceable groups handle conditional logic where content appears or disappears but never moves:

@ComposeCompilerApi
override fun startReplaceableGroup(key: Int) = start(key, null, GroupKind.Group, null)

override fun endReplaceableGroup() = endGroup()

The compiler inserts replaceable groups around if expressions, when branches, early returns, and null coalescing operators. These groups cannot move between siblings. They can only be inserted or removed entirely.

// What you write:
if (showHeader) {
    Header()
}
Content()

// What the compiler generates (conceptually):
composer.startReplaceableGroup(123) // 123 is a source location key
if (showHeader) {
    Header()
}
composer.endReplaceableGroup()
Content()

The integer key (123 in this example) is generated from the source location. It uniquely identifies this group among its siblings.

Movable groups

Movable groups are created by explicit key() calls. Unlike replaceable groups, they can be reordered among siblings while preserving their internal state:

@ComposeCompilerApi
override fun startMovableGroup(key: Int, dataKey: Any?) =
    start(key, dataKey, GroupKind.Group, null)

@ComposeCompilerApi
override fun endMovableGroup() = endGroup()

The critical difference is the dataKey parameter. While key is still a source location identifier, dataKey carries the user provided identity from the key() call.

From the source documentation:

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