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)
  }
}
  1. Foo
  2. Button
  3. Button's content lambda
  4. Text

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.

📖
This article is part of a series on Compose state. Check out the other articles here.

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".

  1. When you click the button, the text MutableState's value is changed.
  2. The only function that reads that state object is the button’s content lambda, and so the content lambda gets invalidated.
  3. The content lambda eventually gets re-executed, which reads the new text value, and passes it to Text.
  4. (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.