Skip to main content

Same Type, No Guarantees: Why Compose Distrusts Your List, Set and Map

Unstable collections quietly break skipping in Jetpack Compose. Here are two fixes, four real failure modes, and how to choose.

Updated 9 min readMarkdown
Same Type, No Guarantees: Why Compose Distrusts Your List, Set and Map

You open the Compose compiler report and find a screen that refuses to skip. The composable takes a CheckoutUiState. The state holds an Order. The order holds a List<Item>. Nothing in the UI layer ever touches a mutable collection, yet the compiler flags the whole chain as unstable.

This post explains why that happens, the two fixes that actually work in production, and the quiet ways each one can fail.

Why standard collections are unstable

Compose decides whether a composable can be skipped by comparing its parameters with the previous call. That comparison is only safe when the compiler can trust that a “same” value really is the same. A stable type promises that if two instances are equals(), they behave identically, and that any change in public state is reported to Compose.

kotlin.collections.List, Set and Map are interfaces. A read-only interface describes what you may do through that reference. It says nothing about what the object behind it can do.

interface-hides-mutability
class SearchViewModel {
    private val buffer = ArrayList<Result>()

    // Callers see List<Result>, but the instance is the same mutable buffer.
    val results: List<Result> get() = buffer

    fun onResult(r: Result) {
        buffer += r   // mutates state Compose will never be told about
    }
}

The compiler cannot inspect the runtime class, so it takes the safe route: a parameter typed List<T> is unstable. That is the correct call, not a compiler quirk. You can verify it yourself.

Context worth knowing: since Kotlin 2.0.20, strong skipping mode is enabled by default. It lets composables with unstable parameters skip when the same instance is passed again (compared by reference). That softens the problem, but it does not remove it: any code path that produces a fresh list on each emission still triggers recomposition, and stable parameters get equals() comparison, which is what you usually want for collections. Everything below applies regardless of that mode.

Instability is contagious

The expensive part is not the collection itself. It is where the collection hides.

instability-propagation
// Domain layer: perfectly reasonable Kotlin.
data class Order(
    val id: String,
    val items: List<Item>,        // <- the only unstable field
    val total: Money,
)

// Presentation layer.
data class CheckoutUiState(
    val order: Order,
    val isSubmitting: Boolean,
)

@Composable
fun CheckoutScreen(state: CheckoutUiState, onPay: () -> Unit) { /* ... */ }

A class is stable only if all its properties are stable. One List in a domain model makes Order unstable, which makes CheckoutUiState unstable, which makes CheckoutScreen's parameter unstable. The developer who wrote CheckoutScreen never typed List, so the bug has no obvious owner.

To see it, generate compiler reports:

// build.gradle.kts (module)
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

Look in the *-classes.txt output for unstable val items: List<Item> and in *-composables.txt for restartable-but-not-skippable functions.

Fix one: persistent collections

The kotlinx.collections.immutable library provides PersistentList, PersistentSet and PersistentMap, plus the read-only ImmutableList, ImmutableSet and ImmutableMap interfaces. The Compose compiler recognizes these as stable, because they represent truly immutable data. There is no mutable instance hiding behind the reference.

// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
}
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList

data class Order(
    val id: String,
    val items: ImmutableList<Item>,
    val total: Money,
)

@Immutable
data class CheckoutUiState(
    val order: Order,
    val isSubmitting: Boolean = false,
)

// Updating produces a new value instead of mutating the old one.
private fun Order.withItem(item: Item): Order =
    copy(items = items.toPersistentList().add(item))

Why this is the cleaner option:

  • The guarantee lives in the type system. A teammate cannot accidentally mutate what the UI holds.
  • No hidden convention. A new developer reading ImmutableList<Item> gets the contract for free.
  • Structural sharing means updates are cheaper than copying a whole ArrayList.

What you pay: conversions at boundaries, and a decision about whether the library leaks into your domain module.

Failure mode 1: the half-migrated domain model

You convert CheckoutUiState to ImmutableList but map from a domain object that still carries List:

data class CheckoutUiState(
    val order: DomainOrder,          // still has List<Item> inside
    val isSubmitting: Boolean,
)

The ImmutableList you added elsewhere makes no difference. The state is unstable because of the one field you did not look at. The fix is to convert at the mapping boundary so the UI only ever sees UI models:

fun DomainOrder.toUi(): OrderUi = OrderUi(
    id = id,
    items = items.map(Item::toUi).toPersistentList(),
    total = total,
)

Failure mode 2: converting inside composition

@Composable
fun ItemsPane(items: List<Item>) {
    // Allocates and copies on every recomposition.
    ItemsList(items = items.toPersistentList())
}
rebuild-on-recomposition

A safer version is remember(items) { items.toPersistentList() }. The version above silences the compiler warning but doesn't fix the design. You pay an O(n) copy each pass, and if the upstream list is a fresh instance each time you also lose cheap equality checks. Convert once, where the data is produced (a ViewModel, mapper or repository), and keep the result in state:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.*

class CartViewModel(repo: CartRepository) : ViewModel() {
    val uiState: StateFlow<CartUiState> = repo.items
        .map { CartUiState(items = it.map(Item::toUi).toPersistentList()) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CartUiState())
}

If you truly must adapt inside a composable, wrap it in remember(items) { items.toPersistentList() } so the cost is tied to a real input change.

Fix two: stability configuration

Compose lets you declare, in a plain text file, that certain types should be treated as stable. This overrides the compiler’s conservative default for the types you list.

// stability_config.conf
kotlin.collections.List
kotlin.collections.Set
kotlin.collections.Map

Wire it into the Compose compiler Gradle plugin:

// build.gradle.kts (module)
composeCompiler {
    stabilityConfigurationFiles.add(
        rootProject.layout.projectDirectory.file("stability_config.conf")
    )
}

Your existing domain models, with plain List<Item>, instantly become stable. No dependency, no mapper changes, no migration.

The catch is that you have replaced a compiler proof with a team promise: “nobody mutates a collection after handing it to the UI.” That promise is only as good as your discipline.

Failure mode 3: the stale-UI mutation

data class FeedState(
    val items: List<Post>,
    val isRefreshing: Boolean = false,
)

class FeedViewModel : ViewModel() {
    private val items = mutableListOf<Post>()
    private val _state = MutableStateFlow(FeedState(items, isRefreshing = true))
    val state: StateFlow<FeedState> = _state

    fun onRefreshed(post: Post) {
        items += post                                   // mutates the list already inside the state
        _state.update { it.copy(isRefreshing = false) } // emits, but `items` is the same instance
    }
}

Two separate mechanisms can hide this update, and it helps to tell them apart:

  1. StateFlow conflation. StateFlow drops a new value that is equals() to the current one. If the only thing you did was mutate the shared list and re-assign FeedState(items), the two states compare equal and nothing is emitted at all. Compose never hears about it, and stability settings are irrelevant.
  2. Compose skipping. In the example above the state does emit, because isRefreshing changed. But ItemsList(state.items) receives the same list instance as before. With List configured as stable, Compose compares it with equals(), finds it equal to itself, and skips. With the default, unstable List, strong skipping compares by reference and reaches the same result. Either way the new post never appears.

Configuration removes the compiler’s last defensive layer here, so the mutation discipline has to come from you. The safe pattern is copy-on-write, even with plain List:

fun onRefreshed(post: Post) {
    _state.update { it.copy(items = it.items + post, isRefreshing = false) }
}

Failure mode 4: over-broad patterns

Writing kotlin.collections.* instead is convenient, but it also marks MutableList, ArrayList and HashMap as stable, which are the types where the promise is most likely to be broken. Prefer naming only the three read-only interfaces, as in the file above, to keep that safety net.

Also confirm the file is really being applied. Re-run the reports and check that the affected classes moved from unstable to stable. A typo in the path fails quietly.

Side by side

two-fixes-compared

Choosing for your project

Both approaches are legitimate. They answer different questions.

decision-flow

Persistent collections are cleaner: the compiler enforces what the types claim, and the safety survives team growth and turnover. Stability configuration is simpler: a few lines in one file, plus the commitment to copy-on-write everywhere.

A reasonable rule of thumb:

  • Small team, strict unidirectional data flow, and a large existing codebase? Configuration gets you most of the benefit quickly. Back it with code review and compiler reports in CI.
  • Multi-module project, many contributors, or a greenfield UI layer? Persistent collections in the UI models cost a little mapping code and buy durable correctness.
  • Mixed? Use persistent collections in the UI layer and keep the domain layer free of the dependency.

Whichever you pick, measure first. Generate the reports, find the composables that matter (hot lists, frequently updated screens), and confirm the change by running them again. Stability is a means to fewer unnecessary recompositions, not a goal on its own.

Checklist

  1. Generate Compose compiler reports and read unstable entries in the classes file.
  2. Trace each unstable class to the field that causes it, often a domain object three layers away.
  3. Choose persistent collections or stability configuration deliberately, and write the decision down.
  4. Convert once at the producer, never inside composition without remember.
  5. Treat state as copy-on-write. Never mutate a collection that has already been published to the UI.
  6. Re-run the reports after every change to confirm the result.

📱 Stop Guessing Whether Your Android Code Actually Works

If “we’ll add tests later” is a sentence your team says a little too often, I’d recommend “Mastering Android Testing” — a 35-chapter deep dive into why Android testing is uniquely hard (Activity/Fragment coupling, async complexity, tooling churn) and exactly how to fix it, layer by layer. It walks through architecting for testability, JUnit/Truth/MockK fundamentals, ViewModel and coroutine/Flow testing without flakiness, Compose UI testing and the semantics tree, Espresso and instrumented tests, Room/Retrofit data-layer testing with a full sync-conflict-resolution case study, Robolectric’s real limits, CI pipelines, flaky test triage, and how to build a testing culture that actually survives contact with a deadline.


Originally published on Medium.

About the author

  • Android Engineer & Kotlin Advocate. 🚀 Deep dives into Jetpack Compose, Coroutines, and Clean Architecture. Turning complex code into scalable solutions. 🛠️✨

No comments yet. Be the first to comment.