min
03/10/2026, 7:52 AM@Composable
fun MyComponent() {
Log.d("SCOPE", "🍎 MyComponent")
var counter by remember { mutableIntStateOf(0) }
val readInvalidatedOnClick = counter
MyButton(onClick = {
Log.d("SCOPE", "⭐️ MyComponent/onClick -> MyButton")
counter += 1
}) {
Log.d("SCOPE", "🍎 MyComponent/content -> MyButton")
CustomText(text = counter.toString())
}
}
I don’t understand the behaviour of this functionmin
03/10/2026, 7:55 AM@Composable
fun MyButton(onClick: () -> Unit, content: @Composable () -> Unit) {
Log.d("SCOPE", "⚾️ MyButton")
Button(onClick = onClick) {
Log.d("SCOPE", "⚾️ MyButton/content -> Button")
content()
}
}
@Composable
fun CustomText(text: String) {
Log.d("SCOPE", "❄️ CustomText")
Text(text = text)
}
Here's the rest of the code.
------- PROCESS STARTED (7742) for package com.example.composedemo -------
SCOPE com.example.composedemo D 🍎 MyComponent
SCOPE com.example.composedemo D ⚾️ MyButton
SCOPE com.example.composedemo D ⚾️ MyButton/content -> Button
SCOPE com.example.composedemo D 🍎 MyComponent/content -> MyButton
SCOPE com.example.composedemo D ❄️ CustomText
SCOPE com.example.composedemo D ⭐️ MyComponent/onClick -> MyButton
SCOPE com.example.composedemo D 🍎 MyComponent
SCOPE com.example.composedemo D 🍎 MyComponent/content -> MyButton
SCOPE com.example.composedemo D ❄️ CustomText
And these are logged when MyComponent is loaded and the button is clicked.min
03/10/2026, 7:59 AMreadInvalidatedOnClick in MyComponent is invalidated on button click, which in turn invalidates the MyComponent recomposition scope. But when the scope (ie the body of MyComponent) runs again on recomposition, it skips the call to MyButton. How is this achieved? I thought just the body of each @Composable function that returns Unit and isn’t inline was wrapped in a recomposition scope. Is each call to a @Composable also wrapped in an independent recomposition scope that knows to skip itself when it’s not been invalidated?min
03/10/2026, 8:05 AMMyComponent does call MyButton on recomposition, but the body of MyButton is gated by its recomposition scope…
// pseudocode
@Composable
fun MyButton(onClick: () -> Unit, content: @Composable () -> Unit) {
recompositionScope {/* ... */}
// ^^^^^^^^^^^^^^^^^^^^ ^Generated by the Compose compiler
// ^^^^^^^^^The original body source
}
…and that’s what sees that it’s not been invalidated and skips running the original body?Zach Klippenstein (he/him) [MOD]
03/10/2026, 6:55 PMmin
03/11/2026, 10:33 AM@Composable
fun MyButton(
onClick: () -> Unit,
unit: Unit = run { Log.d("SCOPE", "unit -> MyButton") },
content: @Composable () -> Unit,
)
Really? I’ve added the unit parameter to MyButton, expecting the lambda to run every time since MyComposable doesn’t pass an argument for it.
----------- PROCESS STARTED for package com.example.composedemo -----------
SCOPE com.example.composedemo D 🍎 MyComponent
SCOPE com.example.composedemo D unit -> MyButton
SCOPE com.example.composedemo D ⚾️ MyButton
SCOPE com.example.composedemo D ⚾️ MyButton/content -> Button
SCOPE com.example.composedemo D 🍎 MyComponent/content -> MyButton
SCOPE com.example.composedemo D ❄️ CustomText
SCOPE com.example.composedemo D ⭐️ MyComponent/onClick -> MyButton
SCOPE com.example.composedemo D 🍎 MyComponent
SCOPE com.example.composedemo D 🍎 MyComponent/content -> MyButton
SCOPE com.example.composedemo D ❄️ CustomText
But it looks like the call to MyButton itself is entirely skipped? If there was a call, which just immediately returned because the recomposition scope was still valid…
SCOPE com.example.composedemo D ⭐️ MyComponent/onClick -> MyButton
SCOPE com.example.composedemo D unit -> MyButton
SCOPE com.example.composedemo D 🍎 MyComponent
SCOPE com.example.composedemo D 🍎 MyComponent/content -> MyButton
SCOPE com.example.composedemo D ❄️ CustomText
…shouldn’t these have been logged on click?min
03/15/2026, 10:01 AMcolumns parameter initially had a default value in the original Kotlin code, but the plugin removes it during the IR stage. In Compose, the semantics of default values differ from standard Kotlin. The plugin rewrites the handling of default values directly into the body of the composable function.
> Decomposing Jetpack Compose
If my unproven hypothesis is correct and MyButton indeed does get called but its recomposition scope that wraps its original body sees that none of its reads has been invalidated and skips running the wrapped body, and if the quoted explanation from a random blog post is to be believed, then that might explain why unit -> MyButton isn’t printed after ⭐ MyComponent/onClick -> MyButton.min
03/16/2026, 7:14 AMBaiqin Wang
03/16/2026, 11:01 AMMyComponent, MyButton and CustomText are all recompose scopes. Your MyButton is roughly compiled to this:
@Composable
@ComposableInferredTarget
fun MyButton(
onClick: Function0<Unit>,
unit: Unit?,
content: Function2<Composer, Int, Unit>,
$composer: Composer?,
$changed: Int,
$default: Int
) {
$composer = Composer.startRestartGroup()
val $dirty: Int = $changed
if (... == ...) {
$dirty = Int.or(...)
}
if (... == ...) {
$dirty = Int.or(...)
}
if ( ... || ... ) {
Composer.startDefaults()
if (... == ... || Composer.defaultsInvalid) {
...
} else {
Composer.skipToGroupEnd()
...
}
Composer.endDefaults()
Log.d(...)
Button(...)
} else {
Composer.skipToGroupEnd()
}
Composer.endRestartGroup()?.updateScope {
...
MyButton(...)
}
}
@Composable
@ComposableTarget
fun CustomText(
text: String,
$composer: Composer?,
$changed: Int
) {
...
val tmp0_safe_receiver: ScopeUpdateScope? = Composer.endRestartGroup()?.updateScope {
...
CustomText(...)
}
}
As you can see, the default parameters are compiled into the body of MyButton, which is quite different from vanilla kt compiler.
For your first question, the reason why compose can skip executing MyButton but execute CustomText is the updateScope call at the end of each recompose scope. It is basically a callback to tell compose runtime what do when current recompose scope invalidates. The callback mostly just recall the recompose scope itself. MyButton doesn't read invalidate state so compose runtime won't invoke the lambda passed to updateScope . But CustomText does read the counter so compose runtime will invoke the lambda passed to updateScopemin
03/16/2026, 11:17 AMupdateScope for the MyButton because no state it’s read gets invalidated. But as the logs show, it does recompose MyComponent, which also makes sense because the readInvalidatedOnClick read gets invalidated on button click. As part of the recomposition of MyComponent, its body must rerun if I’m understanding it correctly, which includes a call to MyButton. How does that call in particular get skipped?Baiqin Wang
03/16/2026, 12:47 PMMyButton. If the arguments of Mybutton doesn't change with respect to stability rules. The if - else will "skip" the execution of the original MyButton function.Baiqin Wang
03/16/2026, 12:52 PMMyButton function is still invoked. The control flow code is still inside MyButton.min
03/17/2026, 5:59 AMrecompositionScope {/* ... */} actually looks like the following.
if ( ... || ... ) {
Composer.startDefaults()
if (... == ... || Composer.defaultsInvalid) {
...
} else {
Composer.skipToGroupEnd()
...
}
Composer.endDefaults()
Log.d(...)
Button(...)
} else {
Composer.skipToGroupEnd()
}
Have I got it right?Baiqin Wang
03/17/2026, 9:34 AMMyButton function is called but hit the second Composer.skipToGroupEnd() so whole function execution is skipped.min
03/17/2026, 11:52 AM(WrappedComposition as Composition).setContent takes snapshots to drive recompositions? I’m not expecting to be spoonfed answers, and attached are the notes I’ve been able to put together to show you that I’ve been doing my homework. But I’m new to Android, and I find Compose so vast and relentlessly complicated that I’m overwhelmed and struggling to understand how it works. As you can see, I think I understand the call chain from ComposeView.setContent in MainActivity: ComponentActivity to WrappedComposition.setContent. But how does it go from there to store the `.updateScope {}`s, and who gets notified when and how to call them?Baiqin Wang
03/17/2026, 2:08 PMComposerImpl) to every composable function. As you can see here: https://kotlinlang.slack.com/archives/CJLTWPH7S/p1773658889571999?thread_ts=1773129172.444859&cid=CJLTWPH7S. The code composing is also written by the composer. So it is crucial to understand how the compiler works. The most important part of the code written by compose compiler is the "tree traversal code". Like the startRestartGroup (it effectively creates a tree node and traverse down the node). And corresponding end.. functions just traverse up the UI tree. The data of the traversal is stored in a data structure called SlotTable and wrapped in a class called CompositionImpl. After composation, there is a UI tree ready for render in SlotTable. The tree is handled to compose ui module via the Applier interface.
The snapshot updates are triggered from the state property delegates. You can read the source of how compose rewrite the getter and setter of State<> as a starting point. The states updates are also recorded in slot table.
Initial composition is triggered at the time when setContent {} is called. Recomposition is triggered every frame update in Choreographer callback. The core code is in Recomposer class.
Compose itself is platform agnostic. You can use it on other platforms like web and ios. The AndroidComposeView is the core class to glue compose to android platform.Baiqin Wang
03/17/2026, 2:11 PM