30 March 2026
When I first started writing Compose, I treated composable functions like regular Kotlin functions that happened to return UI. Call them with the right parameters, get the right output. Simple. But the more I dug into recomposition bugs â UI not updating, composables re-executing when they shouldnât, unexplained jank during scrolling â the more I realized that composable functions are fundamentally different from regular functions. The @Composable annotation isnât just a marker. It rewrites how the compiler treats your code, and understanding that rewrite is the difference between fighting Compose and working with it.
I spent a lot of time reading the Compose compiler source and studying how engineers like Zack Klipp and Leland Richardson describe the internals. What follows is how I think about composable functions, recomposition, and the stability system â the mental model that finally made the âmagicâ feel predictable.
When you mark a function with @Composable, the Compose compiler plugin transforms it at compile time. The most important transformation: it adds a hidden Composer parameter to every composable function. Your @Composable fun Greeting(name: String) becomes something like fun Greeting(name: String, $composer: Composer, $changed: Int) in the generated bytecode. You never see this parameter, but itâs always there.
The Composer is the engine behind everything. It manages the slot table â a flat, gap-buffer-backed array where Compose stores the state of your composition. Every remember call, every mutableStateOf, every composable invocation occupies entries in this slot table. When your composable executes during initial composition, the Composer writes new entries. When it re-executes during recomposition, the Composer walks the existing entries, compares outputs, and decides what changed.
// What you write
@Composable
fun UserProfile(user: User) {
val formattedName = remember(user.id) { formatDisplayName(user) }
Text(formattedName)
Text(user.email)
}
// What the compiler generates (simplified)
fun UserProfile(user: User, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(...)
val formattedName = $composer.cache(user.id) { formatDisplayName(user) }
Text(formattedName, $composer, ...)
Text(user.email, $composer, ...)
$composer.endRestartGroup()?.updateScope { ... }
}
The compiler also tags each composable with two important properties: restartable and skippable. A restartable composable can serve as a recomposition scope â Compose can re-enter the function at that point when state changes, without re-executing everything above it. A skippable composable can be entirely skipped if its parameters havenât changed. Most composables are both, but the compiler decides based on the functionâs return type, annotations, and parameter stability. A composable that returns a non-Unit value is never skippable. A composable with unstable parameters is not skippable either (unless strong skipping mode is enabled).
Hereâs the insight that changed how I think about this: a composable function is not âa function that returns UI.â Itâs a node in a composition tree. The Composer maintains a tree of these nodes, tracks which state each node reads, and surgically re-executes only the nodes whose inputs changed. The slot table is the memory of this tree. remember is a slot table read. Recomposition is a tree diff.
Recomposition is the process of Compose calling your composable functions again when state changes. But it doesnât call all of them. The runtime follows specific rules that determine which composables re-execute and which get skipped.
Rule 1: State reads drive recomposition. Compose uses the snapshot system to track every State object read during composition. When a MutableState value changes, the snapshot system notifies the recomposer, which identifies every composable scope that read that state and schedules them for re-execution. If your composable doesnât read any changing state, it never recomposes (after initial composition). This is why moving state reads into lambdas â like Modifier.offset { IntOffset(0, scrollY.value) } â is such a powerful optimization. The read moves from composition phase to layout phase, so the composable itself never recomposes.
Rule 2: Only composables that read changed state recompose. If a parent composable changes state, its children donât automatically recompose. Compose checks each childâs parameters. If the parameters are stable and equal to the previous compositionâs values, the child is skipped. This is âsmart recompositionâ â the runtime does the minimum work necessary.
@Composable
fun Dashboard(viewModel: DashboardViewModel) {
val notifications by viewModel.notificationCount.collectAsStateWithLifecycle()
val userName by viewModel.userName.collectAsStateWithLifecycle()
// When notifications changes, only NotificationBadge recomposes
// UserHeader is skipped because userName didn't change
UserHeader(name = userName)
NotificationBadge(count = notifications)
}
Rule 3: Recomposition can happen in any order. Compose doesnât guarantee that parent composables run before children, or that siblings run left-to-right. The runtime can reorder execution for efficiency. This means you should never write composable code that depends on execution order or stores side effects in shared mutable variables.
Rule 4: Recomposition is optimistic and may be cancelled. Compose starts recomposing based on what it thinks changed. If a new state change arrives before recomposition finishes, Compose may cancel the in-progress recomposition and restart with the new state. This is why composable functions must be side-effect-free â if they write to a shared variable, a cancelled recomposition could leave that variable in an inconsistent state. Side effects belong in LaunchedEffect, DisposableEffect, or callbacks like onClick, not in the composable body.
Rule 5: Composable functions can run very frequently. During animations, composables connected to animated state may run every frame â 60 to 120 times per second. If your composable does expensive work (file I/O, complex calculations, network calls), that work happens on every frame. This is why remember exists: to cache computation results across recompositions.
Composeâs ability to skip composables during recomposition depends entirely on the stability of their parameters. A type is âstableâ in Composeâs terms if it satisfies a specific contract: its equals implementation is consistent (same inputs always produce the same result), and if a public property changes, Compose is notified through the snapshot system.
Types that Compose considers stable by default include all primitives (Int, Float, Boolean, etc.), String, function types (lambdas), and MutableState<T>. Data classes are stable if every property uses a stable type. That last part is where most developers hit problems.
// STABLE â all properties are primitive/String
data class UserProfile(
val id: Long,
val name: String,
val email: String,
)
// UNSTABLE â List is a Kotlin interface, could be mutable underneath
data class SearchResults(
val query: String,
val items: List<Product>, // List makes the whole class unstable
)
When a composable receives unstable parameters, Compose cannot skip it during recomposition â it must always re-execute the function because it canât prove the parameters havenât changed. This is the number one cause of excessive recompositions in real apps. Your composable might look simple and lightweight, but if it takes a List parameter, Compose will re-run it every single time its parent recomposes, even if the list contents are identical.
You can check your composablesâ stability using the Compose compiler reports. Add -P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=/path/to/reports to your Kotlin compiler arguments. The report tells you exactly which composables are restartable, skippable, and which parameters are stable or unstable. I run these reports on every module in our project and treat any skippable regression as a bug.
The @Stable and @Immutable annotations let you override the compilerâs stability inference. @Immutable is a promise that the objectâs properties will never change after construction â stronger than @Stable, which allows mutation as long as itâs observable through Composeâs snapshot system. Use @Immutable for truly immutable data holders. Use @Stable for objects with observable mutable properties, like a state holder backed by MutableState fields.
@Immutable
data class CartState(
val items: List<CartItem>,
val total: Double,
)
@Stable
class FormState {
var email by mutableStateOf("")
var password by mutableStateOf("")
var isValid by mutableStateOf(false)
}
One thing to be careful about: these annotations are promises you make to the compiler, not enforcement mechanisms. If you mark a class @Immutable and then mutate it, Compose will skip recomposition and your UI will be stale. No error, no warning. Just a silent bug.
Compose doesnât recompose individual lines of code. It recomposes at scope boundaries â the nearest restartable composable function. Understanding where these boundaries are is the key to controlling how far state changes propagate through your UI tree.
Every restartable composable function creates a scope. When state changes inside that scope, the entire function body re-executes. This means that a large composable that reads fast-changing state will re-execute everything inside it, even the parts that donât depend on that state.
// BAD: the entire screen recomposes when timer changes
@Composable
fun GameScreen(viewModel: GameViewModel) {
val timer by viewModel.timer.collectAsStateWithLifecycle()
val score by viewModel.score.collectAsStateWithLifecycle()
val playerName by viewModel.playerName.collectAsStateWithLifecycle()
Column {
Text("Player: $playerName")
Text("Score: $score")
Text("Time: $timer") // changes every second
GameBoard(viewModel) // expensive composable re-executes every second
}
}
// GOOD: extract the timer into its own scope
@Composable
fun GameScreen(viewModel: GameViewModel) {
val score by viewModel.score.collectAsStateWithLifecycle()
val playerName by viewModel.playerName.collectAsStateWithLifecycle()
Column {
Text("Player: $playerName")
Text("Score: $score")
TimerDisplay(viewModel) // isolated scope, only this recomposes
GameBoard(viewModel)
}
}
@Composable
private fun TimerDisplay(viewModel: GameViewModel) {
val timer by viewModel.timer.collectAsStateWithLifecycle()
Text("Time: $timer")
}
By extracting TimerDisplay into its own composable, the timerâs state read is now inside a separate scope. When the timer changes, only TimerDisplay recomposes. GameBoard, which is expensive, stays untouched.
Lambda functions also create implicit scopes. The content lambda of Button, Column, Box, and other container composables is a separate restartable scope. This means state reads inside a Button { ... } content block wonât cause the parent composable to recompose â they recompose only the lambda. But CompositionLocal reads break this pattern. A CompositionLocal read anywhere in the tree invalidates the scope where the read occurs. If you read a frequently-changing CompositionLocal high in the tree, youâll see cascading recompositions.
Iâve found that the single most effective performance technique in Compose is pushing state reads as deep and as late as possible. Read state in the composable that actually displays it, not in a parent that passes it down. This naturally creates tight recomposition boundaries.
After working with Compose on production apps, I keep running into the same set of mistakes. Here are the ones that cost the most time to debug.
Every time a composable recomposes, its body re-executes. If you create a new object in the body â a new list, a new data class, a new lambda â that object is a new instance. Even if its contents are identical to the previous recomposition, Compose sees a different reference and considers it âchanged.â Any child composable receiving that object cannot be skipped.
// **Wrong** â creates a new list on every recomposition
@Composable
fun SettingsScreen(prefs: UserPreferences) {
val options = listOf(
Option("Dark Mode", prefs.darkMode),
Option("Notifications", prefs.notifications),
)
OptionsList(options = options) // never skipped, new list every time
}
// **Correct** â remember the list, recompute only when inputs change
@Composable
fun SettingsScreen(prefs: UserPreferences) {
val options = remember(prefs.darkMode, prefs.notifications) {
listOf(
Option("Dark Mode", prefs.darkMode),
Option("Notifications", prefs.notifications),
)
}
OptionsList(options = options)
}
If you compute a value from state without remember, the computation runs on every recomposition. For cheap operations, this is fine. For expensive ones â filtering a large list, formatting dates, regex matching â it adds up fast.
// **Wrong** â filters the entire list on every recomposition
@Composable
fun ContactList(contacts: List<Contact>, query: String) {
val filtered = contacts.filter { it.name.contains(query, ignoreCase = true) }
LazyColumn {
items(filtered, key = { it.id }) { ContactRow(it) }
}
}
// **Correct** â remember the filtered result, recompute only when inputs change
@Composable
fun ContactList(contacts: List<Contact>, query: String) {
val filtered = remember(contacts, query) {
contacts.filter { it.name.contains(query, ignoreCase = true) }
}
LazyColumn {
items(filtered, key = { it.id }) { ContactRow(it) }
}
}
This is the one I see most in production code. You pass a List, Map, or a data class with mutable fields to a child composable, and that child can never be skipped. The fix depends on the situation: wrap in an @Immutable holder, use kotlinx.collections.immutable, or restructure to pass stable primitives instead of the whole object.
// **Wrong** â List parameter makes TagRow always recompose
@Composable
fun ProductCard(product: Product, tags: List<String>) {
Column {
Text(product.name)
TagRow(tags = tags) // recomposes every time parent recomposes
}
}
// **Correct** â wrap in an immutable holder
@Immutable
data class TagList(val values: List<String>)
@Composable
fun ProductCard(product: Product, tags: TagList) {
Column {
Text(product.name)
TagRow(tags = tags) // now skippable
}
}
With strong skipping mode (default since Compose compiler 2.0), some of these issues are mitigated because the runtime falls back to reference equality (===) for unstable types. If you pass the same List instance, the composable is skipped even though List is unstable. But if you create a new list instance with the same contents on every recomposition, youâre back to the same problem. Strong skipping mode changed the failure mode from âalways recomposesâ to ârecomposes when reference changesâ â better, but still something you need to be aware of.
Question 1: You have a composable that takes a data class UserInfo(val name: String, val friends: List<String>) as a parameter. With strong skipping mode disabled, will Compose ever skip this composable during recomposition?
Wrong: Yes, because UserInfo is a data class with a proper equals implementation.
Correct: No. UserInfo contains List<String>, which is unstable. The entire class is inferred as unstable, so Compose will always recompose this composable when its parent recomposes, regardless of the equals result. Stability is checked at the type level, not the value level.
Question 2: You read a MutableState value inside a Modifier.graphicsLayer { } lambda. Does changing that state trigger recomposition of the composable?
Wrong: Yes, any state change triggers recomposition of composables that use it.
Correct: No. graphicsLayer reads state during the draw phase, not the composition phase. The snapshot system only tracks reads for the currently active snapshot scope. Since the read happens in a draw-phase lambda, it triggers a redraw but skips composition and layout entirely.
Build a LiveSearchScreen composable that demonstrates proper recomposition scoping. The screen has a search input at the top and a filtered list of items below. Requirements:
remember-ed TextFieldState or MutableState<String>remember(query, items) to avoid recomputation on unrelated recompositions@Immutable types) so it can be skippedonClick lambda should not capture any unstable references â use a stable identifier like item.id instead of the full item objectRun the Compose compiler metrics on your solution and verify that your list item composable is marked as both restartable and skippable.
Thanks for reading!