RizTech Academy logo
RizTech Academy
Jetpack Compose FundamentalsLesson 3 of 635 min

Recomposition: what actually re-runs

Compose promises to call your functions again when data changes, and to skip the ones whose inputs did not. Both halves matter: the first is why the UI stays correct, the second is why it stays fast. Most Compose performance problems come from not knowing which functions re-run, so this lesson does not describe it. It puts log lines in real composables, taps a button on the emulator, and reads what actually ran.

What recomposition is

When Compose first runs your composables to build the screen, that is composition. When some data they read changes, Compose runs again, but not from the top: it re-runs the smallest composables that read the changed data, and inside them it skips any child composable whose parameters are equal to last time. That second run is recomposition.

Three consequences you should hold on to:

  • A composable can run once, or hundreds of times. You do not decide.
  • A composable whose inputs did not change is usually not run at all.
  • Anything a composable does besides describing UI happens every time it runs.

An experiment you run yourself

To change data on screen you need state, which is module 5's subject. This experiment borrows two lines of it (remember and mutableStateOf) so that a button can change a number. Treat them as "a value Compose watches" for now. Everything here is throwaway: create a file ui/RecompositionDemo.kt and delete it at the end of the lesson.

@Composable
fun RecompositionDemo(modifier: Modifier = Modifier) {
    var taps by remember { mutableStateOf(0) }
    Log.d("Recompose", "RecompositionDemo body, taps = $taps")
    println("println from RecompositionDemo, taps = $taps")
    val byPrice = SamplePrices.all.sortedByDescending { it.modalPrice }
    Log.d("Recompose", "sorted ${byPrice.size} prices")
    Column(modifier = modifier.padding(16.dp)) {
        Button(onClick = { taps++ }) {
            Text("Tapped $taps times")
        }
        TapCount(taps = taps)
        PriceCard(price = SamplePrices.lasalgaonOnion)
        PriceCard(price = SamplePrices.puneOnion.copy())
    }
}

@Composable
fun TapCount(taps: Int) {
    Log.d("Recompose", "TapCount body, taps = $taps")
    Text("The button has been tapped $taps times")
}

The by in var taps by remember needs two imports, androidx.compose.runtime.getValue and androidx.compose.runtime.setValue. Android Studio does not always offer them; if by is underlined in red, add them by hand.

Add one temporary line as the first line of PriceCard's body:

android.util.Log.d("Recompose", "PriceCard body: ${price.market}")

And show the demo from MainActivity:

setContent {
    MaterialTheme {
        Scaffold { innerPadding ->
            RecompositionDemo(modifier = Modifier.padding(innerPadding))
        }
    }
}

Run the app. Open Logcat in Android Studio and type tag:Recompose in its search bar, or from a terminal run adb logcat -s Recompose System.out. Tap the button twice. This is the real output from the emulator, with the timestamps removed:

D Recompose: RecompositionDemo body, taps = 0
I System.out: println from RecompositionDemo, taps = 0
D Recompose: sorted 5 prices
D Recompose: TapCount body, taps = 0
D Recompose: PriceCard body: Lasalgaon APMC
D Recompose: PriceCard body: Pune (Gultekdi) APMC
D Recompose: RecompositionDemo body, taps = 1
I System.out: println from RecompositionDemo, taps = 1
D Recompose: sorted 5 prices
D Recompose: TapCount body, taps = 1
D Recompose: PriceCard body: Pune (Gultekdi) APMC
D Recompose: RecompositionDemo body, taps = 2
I System.out: println from RecompositionDemo, taps = 2
D Recompose: sorted 5 prices
D Recompose: TapCount body, taps = 2
D Recompose: PriceCard body: Pune (Gultekdi) APMC

println works on Android: it goes to Logcat under the tag System.out. Log.d lets you choose the tag and level, which is why real code uses it.

Reading the log

The first six lines are composition: everything ran once. Then each tap produced:

Line Ran again? Why
RecompositionDemo body Yes It reads taps, which changed
sorted 5 prices Yes It is ordinary code in a body that re-ran
TapCount body Yes Its parameter taps changed from 0 to 1
PriceCard body: Lasalgaon APMC No Same parameter as last time, so skipped
PriceCard body: Pune (Gultekdi) APMC Yes Looks the same, but is not, to Compose

The first three are what you would expect. The last two are the interesting ones. Both cards received a price with identical values each time. Lasalgaon was skipped; Pune re-ran on every tap. The difference is .copy(), which builds a new CommodityPrice object on every run, and how Compose compares the old parameter with the new one depends on whether the type is stable.

Stability

Before skipping a composable, Compose compares each parameter with its previous value:

Parameter type Compared with Equal-looking new object
Stable equals() Skipped
Unstable ===, the same instance Re-runs

(Older Compose versions never skipped composables with unstable parameters at all. Since Kotlin 2.0.20 the compiler's strong skipping mode is on by default, which allows skipping by instance.)

A type is stable if Compose can be sure its public values never change behind its back, or that it will be told when they do. The Compose compiler works this out for you:

Stable Unstable
Int, Long, Boolean, String and other primitives A class with any var property
Lambdas and function types List, Set, Map (interfaces; the real object could be mutable)
A class of only val stable properties, compiled with the Compose compiler A class from a library not built with the Compose compiler, such as java.time.LocalDate
Anything you declare stable (below) Any class with an unstable property

CommodityPrice has six vals, five of them String or Long. The sixth is LocalDate, which comes from the Java standard library, and the compiler cannot see inside it. One unstable property makes the whole class unstable.

Seeing what the compiler decided

You do not have to guess. Add a reports folder to the composeCompiler block in app/build.gradle.kts (below the android { } block; create the block if you do not have it yet):

composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
}

Build, then open app/build/compose_compiler/app-classes.txt:

unstable class com.riztechacademy.mandibhav.model.CommodityPrice {
  stable val commodity: String
  stable val market: String
  unstable val date: LocalDate
  stable val minPrice: Long
  stable val maxPrice: Long
  stable val modalPrice: Long
  <runtime stability> = Unstable
}

There is the culprit, named. Remove the reportsDestination line when you are done; the reports cost build time.

Declaring LocalDate stable

LocalDate is immutable: once built, it never changes. You can tell the compiler so with a stability configuration file. Create stability_config.conf in the project root, next to settings.gradle.kts:

// Classes the Compose compiler cannot see are immutable, declared stable so
// that equal values skip recomposition. Only list types that never change.
java.time.LocalDate

and point the compiler at it in app/build.gradle.kts:

composeCompiler {
    stabilityConfigurationFiles.add(
        rootProject.layout.projectDirectory.file("stability_config.conf")
    )
}

The report now says stable class …CommodityPrice and stable val date: LocalDate, and after two taps the Pune line no longer appears. One thing caught me out while checking this: after editing the configuration file, the build did not recompile the classes that were already compiled, and the old behaviour stayed. Run ./gradlew clean assembleDebug after changing the file.

What it costs. The file is a promise, and the compiler believes it. List a class that can change, and Compose will skip a composable whose data really did change, so the screen shows stale values with no error. Only list types you know are immutable. Mandi Bhav keeps this file from now on.

The better habit is still to not build new objects in a composable body. .copy(), listOf(), map { } and filter { } in a body all produce a new object on every run. Do that work before the data reaches the composable.

The trap: doing real work in the body

Look at sorted 5 prices again. It ran on every tap, though the prices never changed. Sorting five prices costs nothing. Sorting the 3,000 prices of a state-wide list, parsing dates, or building a DateTimeFormatter costs real milliseconds, and a mid-range phone has about 16 ms to produce each frame at 60 frames per second. Do it on every recomposition, during a scroll or an animation, and the app stutters.

This is why PriceCard keeps its date formatter outside the function:

private val priceDateFormat = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)

It is built once when the file is first used, not each time a card runs. The three formatRupees calls inside the card are cheap string work and are fine.

There are two ways to stop the sort re-running:

  1. Do it before the composable sees the data. The screen receives a list that is already in the right order. This is the real answer: from module 5 a ViewModel prepares what the screen shows, and module 7 moves the work into the data layer.
  2. Cache it with remember, keyed on its input. A preview of module 5:
val prices = SamplePrices.all
val byPrice = remember(prices) {
    Log.d("Recompose", "sorting ${prices.size} prices")
    prices.sortedByDescending { it.modalPrice }
}

remember(prices) { … } runs the block once, keeps the result, and runs it again only when prices changes. With this version the emulator logged sorting 5 prices once at start-up and never again across both taps. Its cost: the cached list is held in memory as long as the composable is on screen, and if you forget the key, it never updates.

Cleaning up

Delete RecompositionDemo.kt, remove the log line from PriceCard, and put MainActivity back to showing the price card. Log lines in composables are a debugging tool; a log call is itself work that runs on every recomposition.

Check your work

Why RecompositionDemo's body re-ran. It reads taps, which changed.

Why TapCount re-ran. Its parameter changed.

Why the Lasalgaon card was skipped. It received the same instance as last time.

Why the Pune card re-ran with equal values. .copy() made a new instance each time, and CommodityPrice was unstable (because of LocalDate), so Compose compared by instance.

How unstable and stable parameters are compared. Unstable with ===; stable with equals().

Where println output appears on Android. Logcat, tag System.out.

The risk of the stability configuration file. Listing a mutable class makes Compose skip updates the user needed to see.

Two ways to stop a sort re-running. Sort before the data reaches the composable; or remember(input) { … }.

Practice 2. Both cards are skipped on every tap: each receives the same instance as last time.

Practice 3. Yes: restartable skippable … fun com.riztechacademy.mandibhav.ui.PriceCard(.

Practice 5. The class becomes unstable class …CommodityPrice, with stable var note: String listed. A var makes the class unstable even when its type is stable.

Practice 6. It logs once. On the emulator, sorting 5 prices appeared at start-up and not after either tap. The keys of a remember call are compared with equals(), whatever their stability, and the new list is equal to the old one. The stability table is about skipping composables; remember keys follow their own rule.

Practice

  1. Run the experiment and get the same log. Tap five times and count how many times each line appears.
  2. Remove .copy() from the Pune card and predict the log before running it.
  3. Turn on the compiler reports and find PriceCard in app-composables.txt. Is it marked skippable?
  4. Add the stability configuration, run ./gradlew clean assembleDebug, and confirm the Pune line disappears with .copy() back in place.
  5. Break it on purpose: add var note: String = "" as a property of CommodityPrice, rebuild with reports on, and read what the report says about the class. Remove it.
  6. Harder: use the remember version, but pass SamplePrices.all.toList() as the key instead of prices. toList() builds a new list on every run, and List is unstable. Predict whether sorting logs once or on every tap, then run it. If the result surprises you, read the remember section of the strong skipping page linked below.

Official documentation

Next: modifiers, and why the order you write them in changes what you see.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship