Recompose Scopes: How Compose Knows What to Update
Recompose Scopes: How Compose Knows What to Update
Jetpack Compose's declarative UI paradigm promises simplicity: you describe your UI as a function of state, and the framework handles updates automatically. But behind this elegant abstraction lies a sophisticated selective recomposition system that makes Compose remarkably efficient. When a single state variable changes, Compose doesn't re-execute your entire UI tree,it surgically recomposes only the specific composable functions that read that state. This precision is enabled by Recompose Scopes, the runtime tracking mechanism that connects state reads to composable functions and orchestrates minimal UI updates.
In this article, you'll dive deep into how "Recompose Scopes" work, exploring how RecomposeScopeImpl tracks which composables read which state, how invalidation propagates through the composition hierarchy, how the compiler-generated restart lambda enables precise recomposition, how the system determines when to skip recomposition entirely, and how bit-packed flags and token-based tracking optimize memory and performance. This isn't a guide on writing efficient composables; it's an exploration of the runtime machinery that makes selective recomposition possible.
The fundamental problem: How do you know what to recompose?
Consider this simple Compose code:
@Composable
fun UserProfile(userId: String) {
val user by viewModel.userState.collectAsState()
val settings by viewModel.settingsState.collectAsState()
Column {
UserHeader(user.name)
UserAvatar(user.avatarUrl)
SettingsPanel(settings)
}
}
When user changes, only UserHeader and UserAvatar should recompose, SettingsPanel shouldn't, because it didn't read user. But how does Compose know this? The naive approach would be to re-execute everything and compare the results, but that would be expensive. Compose needs to track, at runtime, which composables read which state, so when state changes, only the affected composables are re-executed.
This requires solving several complex problems:
- Dependency tracking: Which composable functions read which state objects?
- Invalidation: When state changes, which scopes should be marked for recomposition?
- Precise restart: How do you re-execute just one composable function with the same parameters?
- Skipping: How do you avoid re-executing functions when nothing they depend on changed?
- Memory: How do you track dependencies without excessive memory overhead?
Recompose Scopes solve these problems through a combination of compiler cooperation and runtime tracking.
RecomposeScopeImpl: The tracking mechanism
Every composable function that might need to recompose gets an associated RecomposeScopeImpl instance. This class, defined in the Compose runtime, is the central bookkeeping structure for selective recomposition.
The RecomposeScopeImpl class encapsulates everything needed to track and restart a composable function:
internal class RecomposeScopeImpl(internal var owner: RecomposeScopeOwner?) :
ScopeUpdateScope, RecomposeScope, IdentifiableRecomposeScope
Compact flag-based state storage
Rather than using multiple boolean fields, RecomposeScopeImpl uses a single integer with bit masks for state:
private var flags: Int = 0
private const val UsedFlag = 0x001 // Scope was used during composition
private const val DefaultsInScopeFlag = 0x002 // Has default parameter calculations
private const val DefaultsInvalidFlag = 0x004 // Default calculations changed
private const val RequiresRecomposeFlag = 0x008 // Direct invalidation occurred
private const val SkippedFlag = 0x010 // Scope was skipped
private const val RereadingFlag = 0x020 // Re-reading tracked instances
private const val ForcedRecomposeFlag = 0x040 // Forced recomposition
private const val ForceReusing = 0x080 // Forced reusing state
private const val Paused = 0x100 // Paused for pausable compositions
private const val Resuming = 0x200 // Resuming from pause
private const val ResetReusing = 0x400 // Reset reusing state
This compact representation saves memory—11 boolean flags fit in a single 32-bit integer instead of consuming 11 bytes (or more with padding). The getters and setters use bitwise operations:
private inline fun getFlag(flag: Int) = flags and flag != 0
private inline fun setFlag(flag: Int, value: Boolean) {
flags = if (value) {
flags or flag
} else {
flags and flag.inv()
}
}
This pattern appears throughout high-performance Compose code—prefer bit-packing over separate booleans for frequently allocated objects.
The anchor: Position in the composition
The scope's anchor field is critical for identity:
var anchor: Anchor? = null
An Anchor is a stable reference to a position in the slot table, Compose's internal data structure for storing composition state. Even as the slot table is modified (groups inserted, removed, moved), anchors automatically adjust to maintain their logical position.
The anchor is created when the scope is first used:
if (scope.anchor == null) {
scope.anchor = if (inserting) {
writer.anchor(writer.parent)
} else {
reader.anchor(reader.parent)
}
}
This anchor serves two purposes:
- Validity checking: If the anchor becomes invalid, the composable was removed from the composition.
- Position tracking: The scope knows where in the slot table its composable is located.
A scope is considered valid only when it has an owner and a valid anchor:
val valid: Boolean
get() = owner != null && anchor?.valid ?: false
The restart lambda: Replaying composition
The most crucial field is the restart lambda:
private var block: ((Composer, Int) -> Unit)? = null
This lambda is set by compiler-generated code and captures how to re-execute the composable with the same parameters. When recomposition is needed, this lambda is invoked:
fun compose(composer: Composer) {
block?.invoke(composer, 1) ?: error("Invalid restart scope")
}
The magic is in how this lambda is created. The compiler transforms every composable function to call updateScope at the end:
@Composable
fun UserHeader(name: String, $composer: Composer) {
$composer.startRestartGroup(12345)
// ... actual composable body ...
$composer.endRestartGroup()?.updateScope { $composer ->
UserHeader(name, $composer)
}
}
Notice the lambda captures name by value. This creates a closure that can replay the exact same composition later, with the exact same parameters. When the scope is invalidated and needs to recompose, Compose just calls this lambda.
The compiler-generated pattern
To understand how scopes work, you need to see what the compiler generates. Here's a simplified transformation:
// Original source
@Composable
fun Greeting(person: Person) {
Text("Hello, ${person.name}!")
}
// After compiler transformation (simplified)
fun Greeting(person: Person, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(67890)
val changed = $composer.changed(person)
if (changed || !$composer.skipping) {
Text("Hello, ${person.name}!", $composer, 0)
} else {
$composer.skipToGroupEnd()
}
$composer.endRestartGroup()?.updateScope { $composer ->
Greeting(person, $composer, $changed or 1)
}
}
The transformation adds several elements:
- startRestartGroup(key): Creates or retrieves the recompose scope
- changed(parameter): Checks if parameters changed since last composition
- Skipping check: If nothing changed and skipping is enabled, skip the body
- endRestartGroup(): Returns the scope if it was used
- updateScope { }: Sets the restart lambda if the scope was used
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