The Seven Group Types in Compose

skydovesJaewoong Eum (skydoves)||16 min read

The Seven Group Types in Compose

Every @Composable function you write produces invisible scaffolding. The Compose compiler wraps each Kotlin construct in a "group" that tells the runtime what it can do during recomposition. A conditional branch gets one type of group. A function body gets another. A key() call gets yet another. These decisions happen at compile time, and they determine whether the runtime can skip, replace, move, or recycle each piece of your UI.

In this article, you'll explore the seven group types in Compose's runtime, examining how replace groups handle conditional branches, how restart groups enable targeted recomposition, how movable groups preserve state across reordering, how node groups bridge the slot table and the UI tree, how reusable groups recycle composition structure, how defaults groups isolate default parameter calculations, and how all seven funnel into a single core function with just three GroupKind values.

The fundamental problem: Why multiple group types?

Consider a composable that mixes several Kotlin constructs together:

@Composable
fun UserCard(user: User, showBio: Boolean) {
    key(user.id) {
        Text(user.name)
        if (showBio) {
            val bio = remember { loadBio(user.id) }
            Text(bio)
        }
    }
}

Each construct here needs different treatment from the runtime. The if branch might disappear entirely when showBio becomes false, so the runtime needs to delete everything inside it. The key(user.id) block might move to a different position in a list, so the runtime needs to find it and relocate it instead of destroying it. The UserCard function itself needs to restart independently when its parameters change. The Text calls need to emit actual nodes into the UI tree.

One group type cannot handle all these cases efficiently. A group that searches for moved children would waste time on a simple if/else branch that will never move. A group that immediately deletes mismatches would destroy state that could have been preserved through a reorder. So the compiler classifies each construct into the group type that gives the runtime exactly the capabilities it needs, and nothing more.

The funnel: Seven entry points, three GroupKind values

All group types funnel into a single core mechanism. The GroupKind value class defines just three distinct representations:

@JvmInline
internal value class GroupKind private constructor(val value: Int) {
    inline val isNode get() = value != Group.value
    inline val isReusable get() = value != Node.value

    companion object {
        val Group = GroupKind(0)
        val Node = GroupKind(1)
        val ReusableNode = GroupKind(2)
    }
}

Three values, but seven group types. Five of those seven use GroupKind.Group. The behavioral difference between them is not in how the slot table stores them, but in the logic each start method runs before or after calling the core start() function. Here is the routing:

Start methodGroupKindobjectKeydata
startReplaceableGroup(key)Groupnullnull
startReplaceGroup(key)Group (fast path)nullnull
startMovableGroup(key, dataKey)GroupdataKeynull
startRestartGroup(key)Group (via replace)nullnull
startDefaults()Groupnullnull
startNode()Nodenullnull
startReusableNode()ReusableNodenullnull

The core start() function accepts all of these variations through a single signature:

private fun start(
    key: Int,
    objectKey: Any?,
    kind: GroupKind,
    data: Any?
)

This function handles insertion, key matching, group movement, and force replacement. Each of the seven start methods either calls start() directly or implements a specialized fast path that skips parts of start() that are unnecessary for that group type.

Replace groups: Wrapping conditional branches

The compiler emits replace groups around if/else branches, when expressions, early returns, and null coalescing patterns. Any place where a subtree might appear or disappear gets wrapped in a replace group.

Consider what the compiler produces for a simple conditional:

@Composable
fun Greeting(name: String?) {
    if (name != null) {
        Text("Hello, $name")
    }
}

The compiler wraps the if body in startReplaceGroup and endReplaceGroup calls with a key derived from the source location. When the runtime encounters startReplaceGroup, it runs a fast path that avoids the full start() machinery. The fast path checks whether the key at the current slot position matches the expected key. If the keys match and there is no object key, the reader simply advances into the existing group:

val slotKey = reader.groupKey
if (slotKey == key && !reader.hasObjectKey) {
    reader.startGroup()
    enterGroup(false, null)
    return
}

If the keys do not match, the runtime takes the replacement path. It deletes the old group from the slot table and switches to insert mode:

if (!reader.isGroupEnd) {
    val removeIndex = nodeIndex
    val startSlot = reader.currentGroup
    recordDelete()
    val nodesToRemove = reader.skipGroup()
    changeListWriter.removeNode(removeIndex, nodesToRemove)
    invalidations.removeRange(startSlot, reader.currentGroup)
}
reader.beginEmpty()
inserting = true
ensureWriter()
writer.beginInsert()
writer.startGroup(key, Composer.Empty)
insertAnchor = writer.anchor(writer.currentGroup)
enterGroup(false, null)

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