Stop Over-Using @Stable: The Performance Tool You're Misunderstanding

 Beyond the @Stable annotation: A senior-level guide to mastering recomposition boundaries and compiler inference.

Stop Over-Using @Stable: The Performance Tool You're Misunderstanding

TL;DR

  • Don’t annotate everything: @Stable is a precision tool, not a default "performance button."
  • Unstable ≠ Automatic Recompose: Unstable types simply prevent the compiler from guaranteeing a skip.
  • Use it when: The compiler fails to infer stability (Interfaces, Multi-module) or you have verified a skipping failure.
  • The Risk: Applying @Stable incorrectly creates "stale UI" bugs that are a nightmare to debug.

How Stability Enables Skipping

Compose relies on a mechanism called Skipping. If a Composable’s inputs haven’t changed, Compose attempts to skip its execution. However, if the compiler cannot prove a type is stable, it fails to apply the skipping optimization.

The Mental Model:

  • Stable Input + equals() unchanged → Skip ✅
  • Unstable Input → Cannot guarantee → Recompose ❗

The Technical Nuance:

Stability affects parameter comparison, not state observation. Compose still tracks State reads independently; stability only dictates whether the Composable can skip based on its arguments.

Note on Strong Skipping Mode: Modern Compose includes “Strong Skipping Mode,” which allows the compiler to skip more aggressively even with certain unstable parameters, reducing the manual burden on developers.

The @Stable Contract

When you apply @Stable, you are signing a contract the compiler trusts without verification. If you break these rules, your UI will fall out of sync with your data:

  1. Result Consistency: equals() must return the same result for the same two instances.
  2. UI Equality: equals() must accurately reflect whether the UI output should change.
  3. Observable Changes: Any property change must notify Compose (e.g., via MutableState).

Real-World Implementation: Before & After

Scenario A: The Redundant Annotation

// BEFORE: The compiler ALREADY infers this as stable. 
// Adding @Stable here is redundant "code rot."
data class User(val id: Int, val name: String)

@Composable
fun UserHeader(user: User) { ... }

Scenario B: The Critical Fix (Interfaces)

// AFTER: Use @Stable here because the compiler cannot "see" 
// through the interface to verify the implementation's stability.
@Stable
interface NavigationItem {
val route: String
val icon: ImageVector
}

@Composable
fun NavBar(item: NavigationItem) { ... }

🚫 Common Mistake: The “Stable” Lie

A frequent performance “fix” that breaks apps is marking a class as @Stable while it holds a standard collection.

@Stable // DO NOT DO THIS
data class TodoState(
val items: List<String>
)

Why it fails: List is treated as unstable because it could be backed by a mutable implementation. If you add an item to the underlying mutable list but the list reference stays the same, equals() passes. Because you promised @Stable, Compose skips the recomposition. Result: Your new Todo item never appears on the screen.

The Fix: Use PersistentList from Kotlinx Immutable Collections or wrap the list in a class marked @Immutable.

How to Detect Unnecessary Recompositions in Jetpack Compose

  1. Layout Inspector: Look for the Skips column. If a Composable is recomposing while the parent changes but “Skips” remains at 0, your parameters are likely the bottleneck.
  2. Compose Compiler Reports: The gold standard. It generates a text report during build time labeling every class as stableunstable, or runtime. This is the only way to see exactly what the compiler sees.

🙋‍♂️ Frequently Asked Questions (FAQs)

Is it a bug to have unstable classes?

No. For simple “leaf” Composables, the cost of a defensive recomposition is often negligible.

Does @Stable guarantee my Composable will skip?

No. It enables the possibility. Recomposition can still occur if the scope is invalidated or internal state changes.

Should I use @Stable by default on all UI State?

No. Trust the compiler first. Manual annotations should be the result of a deliberate optimization choice, not a boilerplate habit.

💬 Final Takeaway

If you feel the need to add @Stable everywhere, you're likely fixing a symptom rather than the architecture. Trust the compiler first, measure second, and annotate last.

Discussion:

  • Have you caught a “stale UI” bug caused by an incorrect @Stable annotation?
  • How has Strong Skipping Mode changed your optimization workflow?
  • What’s the most surprising “unstable” culprit you’ve found in a Compiler Report?

📘 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.

Comments

Popular posts from this blog

No More _state + state: Simplifying ViewModels with Kotlin 2.3

Why You Should Stop Passing ViewModels Around Your Compose UI Tree 🚫

Is Jetpack Compose Making Your APK Fatter? (And How to Fix It)