# 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](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/cb8393a1-fecc-4e80-a062-036f6f125605.png align="center")

```kotlin
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](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/954ffe39-2838-405f-9ddf-82ac613f0b04.png align="center")

```kotlin
// 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:

```kotlin
// 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.

```kotlin
// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
}
```

```kotlin
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`:

```kotlin
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:

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

## Failure mode 2: converting inside composition

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

![rebuild-on-recomposition](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/ffd1a526-e8d6-4662-aebb-fbda55d0addb.png align="center")

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:

```kotlin
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.

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

Wire it into the Compose compiler Gradle plugin:

```kotlin
// 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

```kotlin
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`:

```kotlin
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](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/f74237e6-ed9a-40ac-b35e-cce8cf25d5a1.png align="center")

## Choosing for your project

Both approaches are legitimate. They answer different questions.

![decision-flow](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/37e5017f-25fd-4d5d-9267-f2f754d80c69.png align="center")

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.

- **E-book:** [**Available on Google Play**](https://play.google.com/store/books/details?id=d48HEgAAQBAJ)
- **Kindle Edition:** [**Available on Amazon**](https://www.amazon.com/dp/B0HGXB4LH7)
- **Topmate:** [**Available on Topmate**](https://topmate.io/sivavishnu_r/2275839)
- Also available in **Paperback & Hardcover.**

---

*Originally published on* [*Medium*](https://medium.com/@sivavishnu0705/same-type-no-guarantees-why-compose-distrusts-your-list-set-and-map-044d55d5078e)*.*
