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:
- 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
ViewModelprepares what the screen shows, and module 7 moves the work into the data layer. - 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
- Run the experiment and get the same log. Tap five times and count how many times each line appears.
- Remove
.copy()from the Pune card and predict the log before running it. - Turn on the compiler reports and find
PriceCardinapp-composables.txt. Is it markedskippable? - Add the stability configuration, run
./gradlew clean assembleDebug, and confirm the Pune line disappears with.copy()back in place. - Break it on purpose: add
var note: String = ""as a property ofCommodityPrice, rebuild with reports on, and read what the report says about the class. Remove it. - Harder: use the
rememberversion, but passSamplePrices.all.toList()as the key instead ofprices.toList()builds a new list on every run, andListis unstable. Predict whethersortinglogs once or on every tap, then run it. If the result surprises you, read theremembersection of the strong skipping page linked below.
Official documentation
- Lifecycle of composables — Composition, recomposition and how Compose decides what to skip.
- Stability in Compose — The full rules for stable and unstable types.
- Strong skipping mode — Why unstable parameters are compared by instance.
- Fix stability issues — The configuration file,
@Immutable, and when not to bother. - Compose compiler options — Every option of the
composeCompilerblock, including reports.
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