Basic Intelligent Recomposition in Android Compose

Lucas Kivi
Published on MediumJuly 29th, 2024
Robots work together to rebuild a component within a phone’s UI.

In the Android Compose UI paradigm, recompositions drive UI changes. So if you are using Android Compose, then your app is recomposing, but you may not be taking full advantage of Compose’s capacity for intelligent recomposition. That is OK — it isn’t necessarily obvious how to do so — this article sets out to teach you a bit about it.

Why should you care?

A crucial component to a good app experience is buttery smooth UI. Too many recompositions can cause dropped frames and that is public enemy number one to buttery smooth UI. So it behooves us to be intelligent with our recompositions. Often times there is an animating state that requires recomposing a single UI component many times over a period of time. An app that takes advantage of intelligent recomposition will exclusively recompose just the animating component. In the worst case, a poorly written UI could completely rebuild its entire Composable hierarchy every time that animating component updates. This could be the type of error that results in dropped frames — let’s learn how to avoid that.

The Basics

First let’s get the definition of recomposition:

Recomposition is the process of calling your composable functions again when inputs change. This happens when the function’s inputs change. When Compose recomposes based on new inputs, it only calls the functions or lambdas that might have changed, and skips the rest. By skipping all functions or lambdas that don’t have changed parameters, Compose can recompose efficiently.

This makes it clear that Compose monitors inputs to composable functions and “recomposes” the ones that are updated because it assumes that if its inputs have changed, it’s embedded logic needs to be rerun in order to properly re-render itself. This leads to two basic questions that we need to answer:

  1. The hierarchy of composables are selectively recomposed. So what is special about a composable function that allows it to be added to the composable hierarchy?
  2. Changes in a composable’s inputs inspire recompositions. What constitutes a composable input?

Let’s go over these two things…

Composable Functions

Each function or lambda annotated with @Composable creates a RecomposeScope. This nifty compose runtime component creates a recomposition scope within a hierarchy of recomposition scopes to represent a living screen. If an input to a RecomposeScope is changed, the scope will be invalidated and its content will be rerun with the updated inputs. If an invalidated RecomposeScope contains child RecomposeScopes, Compose will skip recomposing those scopes if they don’t depend on the changing input. At the same time, if a child RecomposeScope is invalidated, it isn’t necessary that its parent is too. It should be known that inline functions (e.g. Column and Row) will not create a new RecomposeScope. Below you can see the hierarchy of RecomposeScopes created by SuperSpecialComponent:

A code block shows a composable function called `SuperSpecialComponent` with a `Button` that contains a `Column`. `SuperSpecialComponent`’s function body is labeled “Super Special Component Scope” and the `Button`’s content lambda is labeled “Button Scope”. Notibly, there is no label on the `Column`’s content lambda.
Hierarchy of recomposition scopes

Both “Button Scope” and “Super Special Component Scope” will observe their own input changes and rebuild themselves whenever necessary. There is no “Column Scope” because Column is an inline function, its content becomes a part of “Super Special Component Scope”.

If SuperSpecialComponent receives a state change that the Button doesn’t care about, then the “Button Scope” can be skipped for recomposition even though its parent RecomposeScope is invalidated.

If the Button’s inputs change but the “Super Special Component Scope” doesn’t read any of those inputs, SuperSpecialComponent will not even be considered for recomposition because a child RecomposeScope being invalidated does not invalidate the parent.

This behavior illuminates an important part of the Compose UI paradigm: nodes of the composable hierarchy can recompose independently if you do it right.

Now that we have a basic understanding of how to create a RecomposeScope hierarchy, let’s discuss what constitutes a composable’s inputs.

Composable Inputs

The obvious inputs to a composable are its parameters. If a composable’s parameters change it will recompose. However, Compose runtime won’t notice just any state change; it requires a special observable type called State. This is a magical Compose runtime component that allows Compose to listen for value changes in order to recompose when new data is received. State related updates are the primary driver of recompositions. This is why you commonly see ViewModel data flows converted into state via Flow<T>.collectAsState() , and state holders emit state backed by some sort of State. If you want your UI to react to your data updates, that data must be backed by State!

A crucial point here is that a composable inputs aren’t just its parameters, it is also any State read that occurs within its RecomposeScope. For example, Component below has two inputs that can cause its recomposition, color and text:

private fun MainComponent() {
val viewModel: MainViewModel = hiltViewModel()
val text by viewModel.textFlow.collectAsState()

SuperSpecialComponent(
text = text,
)
}

private fun SuperSpecialComponent(
text: String, // Input one
) {
val color: State<Color> = animateColorAsState(...)

Button {
Column {
Text(
text = text,
color = color.value, // Input two
)
...
}
}
}

If textFlow is updated, then the MainComponent will be recomposed because it is responsible for reading it’s value. SuperSpecialComponent will recompose due to its parent’s RecomposeScope being invalidated, at which point Compose runtime will notice that it depends on text which has changed. Button will also recompose because it depends on text.

Additionally, Button will recompose each time color is updated by its backing function animateColorAsState(...), while SuperSpecialComponent will not be recomposed because it never reads color within its RecomposeScope.

Now we understand that a composable’s inputs are any State reads that might occur within its RecomposeScope, or any parameters it might have that are updated when its parent recomposes. So how can use this information to build a UI that intelligently recomposes.

What can we do?

Based on what we just covered, recomposition scopes should only read State that they actually depend on. If a component is only reading state to then hand it off to another component, there is probably some optimization that can occur, especially if that state is changing rapidly. So let’s take a look at a bad, good, and great way to handle changing inputs within a composable.

For our example…

We will need some animating State— for that we will use this composable function that produces aState<Int> and increments its Int value every 500 milliseconds.

@Composable
fun produceAnimatingState(): State<Int> {
val animatingState = remember { mutableIntStateOf((0) }
LaunchedEffect(Unit) {
while (coroutineContext.isActive) {
animatingState.intValue++
delay(500)
}
}

return animatingState
}

The Bad Way

Here is an inefficient component that reads State in too high of a scope.

@Composable
fun ComponentRaw() {
val animatingState = produceAnimatingState()

Row {
Text(
text = animatingState.value.toString(),
)
Spacer(...)
Icon(...)
}
}
There is a GIF of a number incrementing beside a static icon.
A demonstration of the component and its changing state.

When the input to Text changes it recomposes, that is great! We want Text to be updated; however, animatingState is read from within ComponentRaw‘s RecomposeScope which invalidates the entire component whenever animatingState changes.

CompnentRaw and Text recompose 83 times while Icon is skipped 83 times.
The recomposition counts for ComponentRaw.

ComponentRaw recomposed 83 times. For each of those recompositions each line of logic within the component is evaluated, and each sub-composable is considered for recomposition! The Icon is correctly skipped, as it is static. Since the Text is the only state that changed, we’d like the ComponentRaw recomposition count to be zero. Let’s see how we can do that.

The Solution

We need to wrap the Text composable that cares about animatingState ‘s value in its own RecomposeScope. Then, we must defer the reading of animatingState‘s value until it is within that scope. There are a few ways to do that, but I am going to go over what I will call The Good Way and The Great Way. In the wild you pretty much only see The Great Way — because it is great — but I do think going over The Good Way is worthwhile because it helps illuminate a point.

The Good Way

We can create a new RecomposeScope as prescribed, and pass theState directly to the composable and read it within. Our new composable is CoolTextState , and it simply wraps the Text that is responsible for reading animatingValue .

@Composable
fun ComponentState() {
val animatingState = produceAnimatingState()

Row {
CoolTextState(
animatingState = animatingState,
)
Spacer(...)
Icon(...)
}
}

@Composable
fun CoolTextState(animatingState: State<Int>) {
Text(
text = animatingState.value.toString(),
)
}
ComponentState never recomposes, while CoolTextState and Text recompose 83 times.
The recomposition counts for ComponentState.

We can see that we have achieved the desired behavior! ComponentState never recomposes. The Icon doesn’t even need to skip recompositions; it is simply never considered for them. Changes to animatingState’s value only recompose the component that cares.

This example shows that it is the actual read of State that triggers the recomposition. We can freely pass it around, only to unravel the value where it is actually appreciated. Compose will notice changes in State and trigger invalidations of the recomposition scopes that care.

This approach is rare because it has a weakness — if you want to provide an Int that is not contained by a State , tough luck. Maybe in some places your component animates and in others it uses a static value. In the latter case it would be overkill to use a State backed value.

So how can we maintain these performance benefits but expect an Int rather than State<Int>?

The Great Way

The more common and better option is known as the provider pattern. You still need the new RecomposeScope, but this time you pass your value through the composable hierarchy with a stable lambda backed by State. (Note that this is just a shorthand description: all lambdas are technically stable and the stability issue is really related to the data being captured.) The contents of this type of lambda will be intelligently evaluated for changes, and you will experience the exact same performance as ComponentState, but you will have a more general solution.

@Composable
fun ComponentProvider() {
val animatingState = produceAnimatingState()

Row {
CoolTextProvider(
animatingValueProvider = { animatingState.value },
)
Spacer(...)
Icon(...)
}
}

@Composable
fun CoolTextProvider(animatingValueProvider: () -> Int) {
Text(
text = animatingValueProvider().toString(),
)
}
ComponentState never recomposes, while CoolTextState and Text recompose 83 times.
The recomposition counts for ComponentProvider.

This is the exact same recomposition behavior as ComponentState! But if we wanted to, we could still provide a static Int, like so:

CoolTextProvider(
animatingValueProvider = { 42 },
)

Onwards and Upwards, Efficiently

If you like buttery smooth UI, then you want to take advantage of intelligent recompositions by localizing your State reads. This will require you to wrap rapidly changing UI components in composable functions in order to build fine grained RecomposeScope’s. With this beautiful recomposition scope hierarchy, you will need to defer State reads by using the provider pattern in order to localize those recompositions. With all of that, you will have UI so buttery smooth that your users won’t be able to get a grip.

Lucas recomposes intelligently at Livefront.

We talk the talk.
See how we walk the walk.

Our Work