Part 6: Mastering Jetpack Compose: Stability, Source of Truth, and Skipping

 The performance masterclass: Navigating stable types, immutable data, and the compiler's intelligent skipping logic.

Stability, Source of Truth, and Skipping

⏪ From Aesthetics to Efficiency In [Part 5: Theming & Design System Architecture], we explored how to use CompositionLocal and Data Flow to give our app its "Skin." But as any engineer knows, a beautiful UI is worthless if it's sluggish.

You’ve heard it a thousand times: “Compose is smart; it only recomposes what’s necessary.” But how smart is it, really? To master the speed of your app, we must understand the “Logic Gates” of the runtime: Stability and the Source of Truth. Today, we learn how to help the compiler make the smartest decisions possible.

The Recomposition Lottery: What Gets Skipped?

When a Composable function re-runs, Compose checks its parameters to decide if it can save time.

  • If all parameters are stable and have not changed, Compose will skip the function’s group in the Slot Table.
  • If even one parameter is unstable or has changed, the Composable must re-execute to determine if the UI output has changed.

What Makes a Type “Stable”?

A type is considered stable if it meets the following criteria for the Compose Compiler:

  1. Immutability: Its properties cannot change after creation.
  2. Structural Equality: equals() must consistently return the same result for the same observable state.
  3. Public Properties: All public properties are themselves stable.

Common Stable Types:

  • Primitives: IntStringBoolean.
  • Lambdas: Most lambda expressions (provided their captured state is also stable).
  • Verified Data Classes: Data classes where all properties are val and are also stable types.
  • State Wrappers: State<T> (The wrapper itself is stable, even if the value inside T changes).

Common Unstable Types:

  • List<T>Set<T>Map<T>: Even though these are read-only interfaces, Compose treats them as unstable because it cannot guarantee the underlying implementation (like ArrayList) won't be mutated elsewhere.
  • Classes with var properties: Since they can change at any time without Compose knowing.
  • External Classes: Types from libraries that haven’t been processed by the Compose compiler.
// Stable: Standard immutable data class
data class User(val id: String, val name: String)

// Unstable: Compose cannot guarantee this List won't change externally
@Composable
fun UserList(users: List<User>) { ... }

// Stable: Using the kotlinx.collections.immutable library
@Composable
fun StableUserList(users: ImmutableList<User>) { ... }

The Source of Truth: Where Does Your State Live?

To achieve high-performance skipping, your state must come from a Single Source of Truth (SSOT) and be exposed in a stable format.

If you pass a regular List<T> from a ViewModel, Compose will likely recompose that list every time the parent recomposes. To fix this, you should lean on State<T> or immutable collection types at the UI boundary.

@Composable
fun ProductScreen(viewModel: ProductViewModel) {
// products is State<List<Product>>
val products by viewModel.products.collectAsState()

// By converting to an ImmutableList, we provide a stable
// contract that allows ProductGrid to be skipped.
ProductGrid(products.toImmutableList())
}

Donut-Hole Skipping: Intelligent Invalidation

One of the most powerful features of Compose is its ability to skip stable children even when a parent is forced to recompose. This is known as Donut-Hole Skipping.

@Composable
fun Parent(unstableParam: Any) {
Column {
// This Text is stable and its parameters never change.
// It will be SKIPPED.
Text("Static Header")

// This reads unstableParam. It MUST RECOMPOSE.
Text("Data: ${unstableParam.hashCode()}")

// This child is stable. If 'user' hasn't changed,
// it will be SKIPPED even though Parent is recomposing.
StableUserCard(user = cachedUser)
}
}

The @Immutable and @Stable Annotations

If you are using a class where the Compose compiler can’t automatically verify stability, you can “promise” stability using annotations:

  • @Immutable: Use this when a class is strictly immutable (all val properties).
  • @Stable: Use this for types that are mutable but will notify Compose of changes (like a custom State-backed object) and where equals() is reliable.

Warning: If you annotate a class as @Immutable but then mutate its properties, you will create "Ghost UI" bugs where the screen doesn't update because Compose skipped a recomposition it thought was unnecessary.

Frequently Asked Questions (FAQs)

Does Stability mean a Composable will NEVER recompose?

No. Stability just allows for skipping if the parameters haven’t changed. If a Composable reads a State object internally and that state changes, it will still recompose regardless of its parameters.

How do I actually see what the compiler thinks of my code?

You can enable Compose Compiler Reports by adding -PcomposeCompilerReports=true to your Gradle build. This generates reports showing the stability, skippability, and restartability decisions made for every function in your app.

Is MutableState<T> stable?

Yes. The MutableState object is stable. When you pass it as a parameter, Compose knows it can skip the Composable if the reference to the state object is the same, even though the value held inside it is designed to change.

Reader Challenge: The Stability Audit

If you have a data class where one property is a standard List<String>, that class is technically unstable. Without changing the list to an ImmutableList, how can you use annotations to ensure any Composable using this class remains skippable?

Post your solutions (and the potential risks) in the comments!

Next Up: Mastering the Physics of UI We’ve mastered the “Logic Gates” of the Compose Compiler — ensuring our functions only run when they absolutely have to. But once a function runs, how does it decide exactly how many pixels to take up? How do we build layouts that Rows and Columns can’t handle?

In [Part 7: Advanced Modifiers & Custom Layouts], we move from the engine to the blueprint, mastering the three-step process of Measurement, Sizing, and Placement.

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