Scoped recomposition in Jetpack Compose — what happens when state changes?
Yesterday I posted a code snippet on Twitter with a poll asking how Compose would choose to re-execute it when the state changes. Given this code, which functions will be called on the next composition?
@Composable fun Foo() {
var text by remember { mutableStateOf("") }
Button(onClick = { text = "$text\n$text" }) {
Text(text)
}
}
Foo
Button
Button
's content lambdaText
I had initially meant to reply as a short thread on Twitter directly, but short turned into long and I found out there's actually a limit on the number of tweets you can post in a single thread, so I took it as a sign I should write a proper blog post instead.
Show me the answer!
So, there are a actually few answers depending how you interpret the options in the poll - my bad for writing so ambiguously.
The most important answer was immediately tweeted by Sean McQuillan (@objcode), and parrots what other members of the Compose team at Google have said many times:
In other words, it doesn't matter. If you've followed the Compose best practices, your code will Just Work, regardless of what fancy optimizations Compose decides to do under the hood.
That will be great in a few years months, when you and your team have internalized everything and barely remember what a LayoutParam
is. It's great to be able to ignore the details and just read/write code. But when learning something new, it's usually helpful to peek behind the curtain and understand why things work the way they do.
High-level analysis
If you’re writing this code and trying to reason about what your code is doing (and not, for example, how Button
works internally), the answer you probably care about the most is the third one: "Button
's content lambda".
- When you click the button, the text
MutableState
's value is changed. - The only function that reads that state object is the button’s content lambda, and so the content lambda gets invalidated.
- The content lambda eventually gets re-executed, which reads the new text value, and passes it to
Text
. - (really 3 ½) Because the text is different,
Text
will then be ran as well.
A lot of people expected something else, so I'll run through why the other answers are wrong "less right" in the order they were voted.
Some fundamentals
Before diving into this particular code snippet, let's go over some basics.
Recompose scopes
Recompose scopes are an important piece of the Compose puzzle. They do some bookkeeping and help reduce the amount of work Compose has to do to prepare a frame.
- They are the smallest unit of a composition that can be re-executed (recomposed) to update the underlying tree.
- They keep track of what snapshot-based
State
objects are read inside of them, and get invalidated when those states change.
For every non-inline composable function that returns Unit
, the Compose compiler generates code that wraps the function’s body in a recompose scope. When a recompose scope is invalidated, the compose runtime will ensure the (entire) function body gets recomposed (reexecuted) before the next frame. Functions are a natural delimiter for re-executable chunks of code, because they already have well-defined entry and exit points.
Foo
’s body, Button
’s body, the content lambda we pass to Button
, Text
’s body, all get their own recompose scopes.
What about inline functions and functions that return a value?
Inline functions are inline - their bodies are effectively copied into their caller, so they share their caller’s recompose scope.
Functions that have a non-unit return value don’t get their own scopes because they can’t be re-executed without also re-executing their caller, so their caller can “see” the new return value computed by the function.
Expressions and function calls
Before getting into any details about Compose itself, let's refresh a fundamental concept about how regular old function calls work. In Java/Kotlin, when you pass an expression to a function, that expression is evaluated in the calling function.
println(“hello” + “world”)
is basically equivalent to
val arg = “hello” + “world”
println(arg)
The values of all the arguments to a function call have to be evaluated before the JVM/ART can call the function, so that it can pass the results of those expressions to the function. You can also see this by doing println(TODO())
- println
will be highlighted as unreachable code, because the TODO()
never returns, so the println
call will never be hit.
In the original code snippet, this means that the compiler generates the code to read the text
state inside the content lambda, and then passes the result of the read to Text
. The Compose compiler doesn’t know that the value of the read is only used in the one spot. At least right now - it could, potentially, figure this out via data flow analysis, but it is not that fancy at the moment. All it knows is that the content lambda reads text
.
Show your work
Let's look at our code snippet again:
@Composable fun Foo() {
var text by remember { mutableStateOf("") }
Button(onClick = { text = "$text\n$text" }) {
Text(text)
}
}
Why isn’t just Text
recomposed?
If we think of the text
read as happening on its own line just above the Text
call, then look for the nearest recompose scope enclosing that - it’s the button content lambda. That’s the recompose scope that gets invalidated when the text state value is changed.
So when text is changed, and the lambda’s recompose scope is invalidated, the entire lambda body gets recomposed for the next frame.
Why isn't all of Foo
recomposed?
Now that we know how recompose scopes are delimited and invalidated, we can see that nothing directly in Foo
reads any State
, so it will never actually recompose.
This might be more apparent if we get rid of the delegate syntax for text
, which doesn't change behavior but hides what's actually going on a bit:
@Composable fun Foo() {
val text: MutableState<String> = remember { mutableStateOf("") }
Button(onClick = { text.value = "${text.value}\n${text.value}" }) {
Text(text.value)
}
}
It's a lot more clear now that all Foo
does with text
is create the MutableState
value holder. The actual object referenced by text
doesn't change, it always points to the same MutableState
instance. When we talk about state "reads", we mean reads of the State
's value
property – e.g. text.value
.
Why isn't Button
recomposed?
Because Foo
isn't recomposed, nothing calls the Button
function itself on recomposition.
However, this is a bit of a trick question: There may or may not be functions deep inside Button
's implementation that recompose in certain cases, but that doesn't affect our code, so we can ignore those. Button
does maintain some private state, so it can show its pressed status and the ripple, but they might not actually require recomposition, instead they can just request re-draws.
What about the onClick
lambda?
Recompose scopes are only created around composable functions. Event handlers, like Button
's onClick
, are not composable, they're just regular functions. When the click handler is invoked by the framework, it is done so outside of any recompose scope.
Caveat: Inline composable functions
You might be surprised to find out (and I often forget) that common layouts like Column
, Row
, and Box
are all inline functions. Even the core Layout
composable itself is inline. Ignoring Compose for a second, remember that when a Kotlin function is marked inline
, its body is always copied into its call sites by the compiler – it doesn't even exist as an entity in the bytecode. This is most often used in regular Kotlin code to avoid allocating objects to hold lambda functions for functions that are simple wrappers around other functions.
Compose uses inlining for a similar optimization. Because the body of inline composable functions are simply copied into their call sites, such functions do not get their own recompose scopes. In the following snippet, when the lambda passed to Wrapper
gets recomposed, the App
body will be recomposed as well, since Wrapper
is inline and does not have its own recompose scope. In other words, any time this code prints "App recomposing", "Wrapper recomposing", or "Lambda recomposing", all three will be printed together.
@Composable fun App() {
println("App recomposing")
Wrapper {
println("Lambda recomposing")
// Read some state causing recomposition…
}
}
@Composable inline fun Wrapper(content: @Composable () -> Unit) {
println("Wrapper recomposing")
content()
}
If you wrap the Text
in the original code snippet in a Column
and add trace statements, you’ll see that the invalidated scope is still the entire button content lambda, not just the Column
content lambda.
Final thoughts
This means in a regular program, you might end up recomposing a lot more than you’d expect just from looking at the code. And in general, it’s impossible to determine what will be recomposed by just looking at a particular code snippet without knowing if functions are inline.
It’s also important to remember that a lot of these rules might change in the future. The compose team might change the rules at any time. Luckily, as Sean said, it shouldn’t affect correctness. All this function skipping is just an optimization. This is (one of the reasons) why it’s so important to not do stuff like perform side effects directly in composables. Use LaunchedEffect
, DisposableEffect
, and SideEffect
. Use tools like remember {}
or derivedStateOf {}
for expensive computations.
Follow the documented best practices and you won’t need to worry about exactly what gets recomposed when – now or in the future.
If you’d like to read more, Vinay Gaba has an excellent article about a specific implication of the behaviors described in this post known as “donut-hole skipping”. Also, checkout these other official docs if you'd like to enhance your mental model even further.
Also, check out my other articles about Compose state.
Thanks to Sean for reviewing this post!
Update: Pulled paragraph about inline composable functions into their own section, added more explanation, and an example.
Update (9/19/21): Added link to donut-hole skipping article.