Practical examples to eliminate unnecessary recompositions and squeeze maximum performance from Compose.
Compose decides at runtime whether to skip or re-execute a composable based on whether its parameters changed. Stability is the signal that tells Compose "it's safe to skip".
// ❌ interface = Unknown stability // Compose cannot skip this @Composable fun UserCard(user: User) { Text(user.name) Text(user.email) } // Even if user didn't change, // this ALWAYS re-executes 😱
// ✅ data class = stable (all val) // Compose CAN skip this @Composable fun UserCard(user: UserData) { Text(user.name) Text(user.email) } // If user.equals(prev) → SKIP ✅ // Zero work done! 🚀
equals() to detect changes?" If yes → safe to skip. If no → must re-run to be safe.The compiler represents every type as one of these five stability categories. Knowing them helps you read compiler reports.
| Type | Meaning | Example | Skippable? |
|---|---|---|---|
Certain(true) |
Definitely stable at compile time | data class User(val id: Int) |
✅ Yes |
Certain(false) |
Definitely unstable at compile time | class C(var x: Int) |
❌ No |
Runtime |
Stability depends on generic type args — checked at runtime via $stable field |
class Box<T>(val v: T) |
⚡ Depends |
Unknown |
Interface or abstract — can't know without implementation | interface Repository |
⚠️ Falls back to === |
Parameter |
Depends on generic type parameter T | Wrapper<T> |
⚡ Depends on T |
// Compiler generates $stable field @StabilityInferred(parameters = 0b1) class Box<T>(val value: T) // At usage site: val a = Box<Int>(42) // $stable = STABLE ✅ (Int is stable) val b = Box<MutableList<Int>>(mutableListOf()) // $stable = UNSTABLE ❌
// Interface: compiler can't know // which implementation will come interface DataSource { fun load(): String } @Composable fun Screen(src: DataSource) { // Falls back to reference === // New lambda each recomp = bad! }
These are the most frequent stability killers in real codebases. Each one causes unnecessary recompositions.
// ONE var field = entire class is UNSTABLE data class UserState( val name: String, var loading: Boolean, // ← poison ☠️ val email: String ) @Composable fun Profile(state: UserState) { // NEVER skipped, always re-runs }
// All val = STABLE ✅ data class UserState( val name: String, val loading: Boolean, // ← val ✅ val email: String ) // Create new instance to "update": state = state.copy(loading = true) // data class copy() is idiomatic ✅
// List = interface = Unknown stability // MutableList = unstable @Composable fun ItemList(items: List<String>) { // ⚠️ List is an interface → Unknown // Falls back to reference comparison // listOf() creates new reference each time! items.forEach { Text(it) } }
// ImmutableList is in KnownStableConstructs import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Composable fun ItemList(items: ImmutableList<String>) { // ✅ Stable! Compose can skip safely items.forEach { Text(it) } } // Usage: ItemList(persistentListOf("A", "B", "C"))
// stability_config.conf: kotlin.collections.List kotlin.collections.Map kotlin.collections.Set // Then in build.gradle.kts: composeCompiler { stabilityConfigurationFile = rootProject.layout.projectDirectory .file("stability_config.conf") } // ⚠️ Warning: you're promising these // are stable — use carefully!
// Interface → Unknown stability interface ViewModel { val uiState: UiState fun onAction(action: Action) } @Composable fun Screen(vm: ViewModel) { // ⚠️ Unknown stability // Compose uses === for comparison // Same object reference? Only then skip }
// @Stable on interface = trusted @Stable interface ViewModel { val uiState: UiState fun onAction(action: Action) } @Composable fun Screen(vm: ViewModel) { // ✅ Stable — you promise // all implementations are stable }
// Use concrete type directly data class UiState( val isLoading: Boolean, val items: ImmutableList<Item> ) @Composable fun Screen( uiState: UiState, // ← stable ✅ onAction: (Action) -> Unit // ← lambda stable ✅ ) { // Perfect — skippable! ✅ }
// Third-party class — no @StabilityInferred // → Unstable by default import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ApiResponse( val id: Int, val title: String ) @Composable fun Card(response: ApiResponse) { // ❌ Marked unstable even though // it's all-val, because it's from // an external module }
// Option A: stability_config.conf com.squareup.moshi.JsonClass // Option B: wrapper data class data class CardModel( val id: Int, val title: String ) // Map in ViewModel, not in UI: fun ApiResponse.toCardModel() = CardModel(id = id, title = title) @Composable fun Card(model: CardModel) { // ✅ Fully stable, skippable }
// Unstable base poisons ALL subclasses open class BaseModel( var id: Int // ← var in base = death 💀 ) class UserModel( val name: String ) : BaseModel(0) // UserModel: fields all-val? Doesn't matter. // Superclass has var → UserModel is UNSTABLE
// Fix: Make base class immutable open class BaseModel( val id: Int // ← val ✅ ) data class UserModel( val name: String, val baseId: Int // prefer composition ) // Better: flatten hierarchy entirely // Inheritance in UiState = common mistake
// Contract: nothing changes EVER @Immutable data class Theme( val primary: Color, val background: Color ) // Extra benefit: constructor calls // become "static expressions" // → Better lambda memoization val lambda = { theme.primary } // ↑ Compiler can optimize this more // aggressively with @Immutable
// Contract: // 1. equals() works correctly // 2. State changes trigger recomposition @Stable class CartState { private var _items by mutableStateOf(listOf<Item>()) val items: List<Item> get() = _items fun add(item: Item) { _items = _items + item // mutableStateOf auto-triggers // recomposition ✅ } }
// Define once in your project: @StableMarker @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.BINARY) annotation class UiModel // Use across your codebase: @UiModel data class ProductCard( val id: String, val title: String, val price: Double ) @UiModel data class OrderSummary( val orderId: String, val total: Double ) // Both treated as @Stable ✅
@Immutable enables extra compile-time optimization where constructor calls become static expressions (better lambda memoization). @Stable allows private mutability but doesn't get this bonus. When in doubt on a pure data class: use @Immutable.The most powerful tool for fixing stability on third-party types you don't control. One file, zero code changes to your models.
// ── Exact class ────────────────── com.google.gson.JsonObject // ── Entire package (recursive) ─── com.example.models.** // ── Single-level wildcard ───────── com.example.*.dto // ── Generic: * = matters, _ = skip kotlin.collections.List // no generics com.example.Container<*> // T matters com.example.Either<*,*> // both matter com.example.Cached<*,_> // only first com.example.Complex<*,_,*> // 1st and 3rd
plugins { id("org.jetbrains.kotlin.plugin.compose") } composeCompiler { stabilityConfigurationFile = rootProject.layout .projectDirectory .file("stability_config.conf") // Optional: enable reports reportsDestination = layout.buildDirectory .dir("compose_reports") metricsDestination = layout.buildDirectory .dir("compose_metrics") }
// * = this type param's stability MATTERS // _ = ignore this type param // Pair<A,B> — both A and B matter: kotlin.Pair<*,*> // If Pair<String, MutableList> → UNSTABLE (MutableList is *) // If Pair<String, Int> → STABLE // Result<T> — T matters: kotlin.Result<*> // Hypothetical cache where key type is irrelevant: com.example.Cache<_,*> // Only value type (2nd param) affects stability // Cache<Any, StableData> → STABLE (key ignored)
Enable reports to see exactly which composables are skippable and why your classes are unstable. This is your performance X-ray.
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
// Then: ./gradlew assembleRelease
// Find reports at: build/compose_reports/
// What you WANT to see: stable class User { stable val id: Int stable val name: String } // What you DON'T want: unstable class Counter { unstable var count: Int } // Generic — OK, depends on T: runtime stable class Box<T> { stable val value: T }
// GOOD — skippable ✅ restartable skippable fun UserCard( stable user: User ) // BAD — can't skip ❌ restartable fun CounterCard( unstable counter: Counter ) // ↑ Find these and fix them! // Especially in LazyColumn items
{
"skippableComposables": 45,
"restartableComposables": 50,
"readonlyComposables": 5,
"totalComposables": 100
}
// 🎯 Your goal:
// skippable / restartable → 90%+
// Focus on screens, list items,
// and frequently-recomposed nodes
LazyColumn / LazyRow item slots. Each list item being unskippable means every scroll re-runs all visible items. This is where stability bugs have the most visible FPS impact.// Delegated var is NOT automatically unstable! // The compiler checks the delegate type instead. @Stable class FormState { // ✅ var delegated to MutableState // Compose tracks this automatically var username: String by mutableStateOf("") var password: String by mutableStateOf("") var isValid: Boolean by mutableStateOf(false) } // This is stable because: // 1. Annotated with @Stable // 2. All mutations go through mutableStateOf // 3. Compose will be notified of changes
// Enums = always Certain(true) // No annotation needed! enum class LoadState { IDLE, LOADING, SUCCESS, ERROR } @Composable fun StatusBadge(state: LoadState) { // ✅ Fully skippable, free! when(state) { LoadState.LOADING -> Spinner() LoadState.ERROR -> ErrorView() else -> {} } }
// Generated proto classes that extend: // GeneratedMessageLite or GeneratedMessage // → Compiler marks as stable ✅ // No config needed, works out of box @Composable fun ProtoCard(msg: UserProto) { // ✅ Stable if UserProto extends // GeneratedMessageLite (it does) Text(msg.name) }
// Stability of value class = stability of wrapped type @JvmInline value class UserId(val value: Int) // ✅ Stable — Int is stable @JvmInline value class Tag(val value: String) // ✅ Stable — String is stable // Override with @Stable when underlying type is unstable: @JvmInline @Stable value class StableWrapper(val list: MutableList<Int>) // ✅ @Stable overrides MutableList's instability // ⚠️ YOU are responsible for the contract!
// ❌ Recursive type = compiler cycle detection // → Returns Unstable conservatively data class TreeNode( val value: Int, val left: TreeNode?, // ← cycle! val right: TreeNode? // ← cycle! ) // Even though logically stable, compiler marks UNSTABLE // Workaround: force-annotate @Immutable data class TreeNode( val value: Int, val left: TreeNode?, val right: TreeNode? ) // ✅ Annotation overrides cycle detection
| Type / Pattern | Stability | Action Needed |
|---|---|---|
Int, Boolean, String, lambdas | ✅ Stable | None |
enum class | ✅ Stable | None |
data class all-val, stable fields | ✅ Stable | None |
data class with any var | ❌ Unstable | Change to val, use copy() |
List<T>, Map, Set (interfaces) | ⚠️ Unknown | Use ImmutableList or add to config |
interface parameter | ⚠️ Unknown | Add @Stable or use concrete type |
abstract class | ⚠️ Unknown | Add @Stable if you control it |
| Java type | ❌ Unstable | Wrap in Kotlin data class |
| 3rd-party class | ❌ Unstable | Wrap or add to stability_config.conf |
Generic Box<T> | ⚡ Runtime | Stable when T is stable |
| Class inheriting unstable base | ❌ Unstable | Fix base or flatten hierarchy |
var delegated to mutableStateOf | ✅ OK | Add @Stable to class |
| Protobuf generated class | ✅ Stable | None — auto-detected |
| Recursive / self-ref type | ❌ Unstable | Add @Immutable if truly immutable |
build.gradle.kts → run ./gradlew assembleReleasebuild/compose_reports/module-composables.txt → search for restartable WITHOUT skippablemodule-classes.txt for unstable classes used in those composablesvar → val, List → ImmutableList, interfaces → @Stablestability_config.conf, wire it in Gradleskippable count improves