Why are Function Types Stable in Jetpack Compose?
Unpacking the "Observability Gap" and why () -> Unit is stable even when its data is not.
Why does a lambda that returns unstable data still count as “stable” in Compose? The answer reveals how recomposition actually works under the hood.
TL;DR: In Jetpack Compose, function types (lambdas) are considered stable because their identity is the only observable signal Compose can use during recomposition. However, stability does not guarantee reactivity — state must be read inside composables, not hidden inside lambdas. To maintain performance, you must manually manage identity using remember.
The Mental Model: Compose Recomposition vs. Execution Phase
To understand function stability, you must distinguish between the two distinct timings in a Composable’s lifecycle:
- Recomposition Phase: Compose compares inputs to decide if it can skip a function. If inputs (like a lambda) have the same identity, Compose skips the work.
- Execution Phase: The Composable executes, and any lambdas passed to it may run much later (e.g., when a user clicks a button). Compose does not observe state reads that happen inside these deferred lambdas.
1. The Core Principle: Identity as the Only Signal
In Jetpack Compose, Stability is about Identity, not Behavior.
When the Compose compiler marks a function type as stable, it isn’t making a promise about what that function does. Instead, it recognizes that identity is the only observable signal Compose can use during recomposition.
Why Lambdas are Stable Candidates
In Kotlin, lambdas are objects. Since they don’t override equals(), referential equality (===) and structural equality (==) yield the same result.
- Performance: Identity comparison is extremely cheap (constant-time).
- Determinism: Identity is a binary, predictable signal.
- The Design Choice: Compose cannot analyze or compare function behavior without executing it, which would defeat the purpose of skipping work to save resources.
Even though a lambda may capture changing values, those changes are invisible to the compiler unless they force a change in the lambda’s identity.
2. The “Capture” Trap: Stability is Not Auto-Memoization
A common mistake is assuming the compiler “fixes” lambda identity for you. It does not. A stable type only enables skipping; it doesn’t guarantee it.
Manual Identity Management (remember lambda Compose)
If you define a lambda inside a Composable without remember, you create a new object reference every time the UI attempts to recompose.
@Composable
fun UserScreen(name: String) {
// ⚠️ WARNING: New lambda instance created on every recomposition.
// The stability of the type is irrelevant because the INSTANCE is always different.
val onHeaderClick = { println("Clicked $name") }
// Header will NOT skip because the 'onClick' reference is technically "new" data.
Header(onClick = onHeaderClick)
}The Professional Approach: Tie the lambda’s identity to its captured inputs so the reference only changes when the data does:
// Identity now only changes if 'name' changes.
val onHeaderClick = remember(name) {
{ println("Clicked $name") }
}3. Strong Skipping Mode: Relaxed Rules, Same Detection
Modern projects often enable Strong Skipping Mode. Here is the essential nuance:
- Standard Mode: Only “Stable” parameters allow skipping.
- Strong Skipping: Allows skipping even for “Unstable” parameters (like a standard
List) by using referential equality (===).
Because lambdas rely on referential equality anyway, they fit perfectly into this model. However, Strong Skipping does not bypass change detection. If you forget to remember a lambda, the reference changes, the change signal is triggered, and Compose will still recompose.
4. The Observability Gap (Why Stability ≠ Correctness)
This is the most dangerous pitfall: assuming a stable function will trigger UI updates correctly. Compose favors predictable identity checks over expensive behavioral analysis to keep recomposition fast.
The Execution Timing Problem
Compose tracks state reads that happen during the Composable’s execution. It cannot observe values produced later inside an arbitrary lambda.
@Composable
fun ObservabilityFail(viewModel: MyViewModel) {
// Stable identity because 'viewModel' is a stable receiver.
val onGetData = viewModel::fetchUnstableData
// ⚠️ DANGER: If the data inside 'fetchUnstableData' changes,
// ChildComponent will NOT know it needs to update.
// This breaks the declarative model by hiding state reads outside the observable scope.
ChildComponent(provider = onGetData)
}🚫 Common Mistakes Checklist
- Hiding StateFlow reads inside callbacks: Always read state in the Composable and pass the raw value down to ensure the UI stays reactive.
- Forgetting
rememberon lambdas: In aLazyColumn, an unremembered lambda can trigger recomposition for every visible item on scroll, leading to noticeable jank in large lists. - Assuming Stable = Reactive: A lambda can be stable but fail to trigger a UI update if the state read is “hidden” from the Compose Snapshot system.
🔚 Conclusion: Compose Stability Explained
In Jetpack Compose, stability is not a reflection of what your code does — it is a reflection of what Compose can observe. Function types are marked as stable because their identity is a predictable, high-speed signal. However, true UI correctness depends on keeping your state reads within the observable scope of the composition, rather than tucking them away inside a stable function reference.
Compose doesn’t care what your function does — it only cares whether it changed.
🙋♂️ Frequently Asked Questions (FAQs)
Is a method reference always stable?
Yes, provided the receiver (e.g., the ViewModel) is stable. If the object providing the method is unstable and changes its reference, the method reference identity will change as well.
Why doesn’t Compose “look inside” the lambda?
Executing a function just to see if it might return something different would often be slower than just recomposing the UI. Identity checks are the “fast path” that keeps Compose fluid.
💬 Questions for the Viewers
- Have you ever noticed a performance lag in
LazyColumnthat was fixed byremembering a lambda? - Do you prefer using
viewModel::functionreferences or explicitrememberedlambdas in your production code? - Has Strong Skipping Mode simplified how you handle unstable data classes in your project?
📘 Master Your Next Technical Interview
Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.
- E-book (Best Value! 🚀): $1.99 on Google Play
- Kindle Edition: $3.49 on Amazon
- Also available in Paperback & Hardcover.

Comments
Post a Comment