DEV Community

Cover image for State, MutableState, and SnapshotStateList: What Actually Differs (and How Each One Fails in Production)
Android Expert
Android Expert

Posted on Originally published at Medium

State, MutableState, and SnapshotStateList: What Actually Differs (and How Each One Fails in Production)

Every Compose codebase eventually grows the same three bugs: a list that never updates, a counter that quietly allocates in a scroll handler, and a “read-only” state that someone mutated from the wrong layer. All three trace back to the same question: what are State, MutableState, and SnapshotStateList, and what does Compose really observe?

This post answers that with idiomatic Kotlin, and with the failure modes that only show up once the screen has real data, real rotation, and real users.

🧠 TL;DR (Too Long; Didn’t Read)

  • State<T> is a read-only, observable handle. Compose tracks reads of .value.
  • MutableState<T> adds a setter. A write invalidates every scope that read it.
  • SnapshotStateList<T> is an observable list: in-place mutation is tracked, so you don't copy the list on every change.
  • For primitives, prefer mutableIntStateOf (and the Long/Float/Double variants) and read through .intValue or the by delegate. Otherwise you still box.

1. The type family

state-type-hierarchy

Two things in that picture trip people up:

  1. IntState is State<Int> specialized for primitives, not the generic State<T>. MutableIntState is both an IntState and a MutableState<Int>. It can be passed anywhere a State<Int> is expected, but the primitive-specialized accessors live on IntState.
  2. SnapshotStateList<T> implements MutableList<T>. It is not a State<List<T>>. Compose observes the list object itself, not a .value wrapper around it.

State<T>: a read-only window

val count: State<Int> = mutableIntStateOf(0)
Enter fullscreen mode Exit fullscreen mode

State<T> exposes one property: val value: T. Reading it inside a composable (or inside a derivedStateOf, snapshotFlow, or LaunchedEffect block that runs under snapshot observation) registers a dependency.

The practical use is API design. Keep the mutable handle private and expose the read-only one:

class CounterViewModel : ViewModel() {
    private val _count = mutableIntStateOf(0)
    val count: IntState get() = _count          // read-only to the UI

    fun increment() { _count.intValue++ }
}
Enter fullscreen mode Exit fullscreen mode

Note the type is IntState, not State<Int>. That keeps the primitive read path available to callers.

One caveat worth stating plainly: State<T> is read-only to you, not immutable. If something else holds the MutableState and writes to it, your State reference changes underneath you. That's the point of the abstraction, but it's not a snapshot of a value.

MutableState<T>: reads and writes

val count: MutableState<Int> = mutableStateOf(0)
count.value = 5   // invalidates every scope that read count.value
Enter fullscreen mode Exit fullscreen mode

Two behaviors matter here:

Writes are filtered by an equality policy. mutableStateOf defaults to structuralEqualityPolicy(), so writing a value that is equals() to the current one does nothing. No invalidation, no recomposition. That's usually what you want, and it's exactly why mutating an object in place (section 5) is invisible.

Writes are cheap; reads are the expensive part to get wrong. A write marks dirty scopes. The actual re-execution happens later, in the next recomposition pass. On a normal Android UI that is the next frame; in unit tests or with a custom frame clock it runs when that clock ticks.

read-write-recompose-flow

The consequence is that where you read matters more than where you write. Reading count.value in a parent composable and passing the Int down makes the parent the reader, so the whole parent scope reruns. Passing the State (or a lambda { count.value }) down defers the read to the child, so only the child reruns.

@Composable
fun Screen(count: IntState) {
    // ❌ Parent reads on every change: Screen and everything under it reruns
    // Header(count.intValue)

    // ✅ Read is deferred into Header's own scope
    Header(countProvider = { count.intValue })
}

@Composable
private fun Header(countProvider: () -> Int) {
    Text("Count: ${countProvider()}")
}
Enter fullscreen mode Exit fullscreen mode

2. mutableIntStateOf vs mutableStateOf<Int>: the boxing story, accurately

The common one-liner is “mutableStateOf(0) boxes, mutableIntStateOf(0) doesn't." That's directionally right, but it hides a trap.

boxing-lanes

val a = mutableStateOf(0)       // generic MutableState<Int>: value stored as java.lang.Integer
val b = mutableIntStateOf(0)    // MutableIntState: value stored as a primitive int
Enter fullscreen mode Exit fullscreen mode

The generic version stores a java.lang.Integer. Small values (−128..127) come from the JVM's Integer cache, so you often won't see allocations in a toy demo. Push a scroll offset, an animation tick, or a timer through it and the cache stops helping.

Now the trap:

val count = mutableIntStateOf(0)

val x = count.value      // ⚠️ goes through MutableState<Int>.value → boxes
val y = count.intValue   // ✅ primitive read
var z by count           // ✅ delegate reads/writes intValue (count is a MutableIntState; an IntState-typed handle only allows `val`)
Enter fullscreen mode Exit fullscreen mode

MutableIntState still is a MutableState<Int> for compatibility, so .value is available and does box. Creating the state with the right factory but reading it with .value gets you the worse of both: extra API surface and no benefit.

The same applies to mutableLongStateOf, mutableFloatStateOf, and mutableDoubleStateOf. Recent Compose versions also ship a lint check (AutoboxingStateCreation) that flags mutableStateOf with a primitive; turn it on rather than relying on code review.

Where it genuinely pays off: values written at frame rate (drag offsets, scroll position mirrors, progress). For a boolean toggled twice a minute, don’t bother.

3. SnapshotStateList: observable in place

val items = mutableStateListOf("A", "B", "C")
items.add("D")        // composables reading `items` are invalidated
items.removeAt(0)     // same
items[1] = "X"        // same
Enter fullscreen mode Exit fullscreen mode

mutableStateListOf returns a SnapshotStateList<T>. Every structural change is recorded in the current snapshot, and readers (anything that iterated, indexed, or read size) are invalidated.

Compare with the alternative most people reach for first:

val items = mutableStateOf(listOf("A", "B", "C"))

// items.value.add("D")   // won't compile: List<T> has no add()
items.value = items.value + "D"   // new list, O(n) copy, new reference
Enter fullscreen mode Exit fullscreen mode

Both work. They fail differently.

snapshotstatelist-vs-state-of-list

Which one should you use?

My default rule:

  • mutableStateListOf when the list is large-ish or updated often (chat messages, form rows, selection sets) and you own the mutations in one place.
  • MutableState<List<T>> (or, more commonly, a StateFlow<List<T>> in the ViewModel) when the list is produced wholesale from elsewhere, such as a repository emission, a diff result, or a sort. Replacing the reference is the natural operation.

Don’t leak mutability

class CartViewModel : ViewModel() {
    private val _items = mutableStateListOf<CartItem>()
    val items: List<CartItem> get() = _items     // callers can't add(); reads are still tracked

    fun add(item: CartItem) { _items.add(item) }
    fun remove(id: String) { _items.removeAll { it.id == id } }
}
Enter fullscreen mode Exit fullscreen mode

Exposing the property as List<T> hides the mutators from callers while keeping observability, because the object behind the interface is still the SnapshotStateList. (Under strong skipping, now the default in the Kotlin 2.0.20+ Compose compiler, passing it as a List<T> parameter is fine: unstable parameters compare by instance, and the child recomposes because it reads the list.)

4. Failure modes

failure-modes

Here’s the code behind the four that cost the most debugging time.

4.1 The list that never updates

// ❌ Same instance, mutated. equals() is trivially true, so no invalidation.
val items = mutableStateOf(mutableListOf("A"))
items.value.add("B")

// ✅ Option 1: observable list
val items = mutableStateListOf("A")
items.add("B")

// ✅ Option 2: replace the reference
val items = mutableStateOf(listOf("A"))
items.value = items.value + "B"
Enter fullscreen mode Exit fullscreen mode

Typing MutableList inside mutableStateOf is the root cause. If you catch yourself writing that, switch to mutableStateListOf.

4.2 The invisible element edit

SnapshotStateList tracks the list, not the objects inside it.

data class Task(val id: String, var done: Boolean)   // ❌ var inside a data class

val tasks = mutableStateListOf(Task("1", false))
tasks[0].done = true      // list unchanged → no recomposition
Enter fullscreen mode Exit fullscreen mode

Make items immutable and replace the element, which is a tracked list write:

data class Task(val id: String, val done: Boolean)

tasks[index] = tasks[index].copy(done = true)
Enter fullscreen mode Exit fullscreen mode

This also makes strong skipping and equality-based skipping behave the way you expect.

4.3 Rotation eats your list

rememberSaveable has no default saver for SnapshotStateList. If you need the list to survive configuration changes or process death at the UI layer, provide one:

@Composable
fun rememberSaveableStringList(initial: List<String> = emptyList()): SnapshotStateList<String> =
    rememberSaveable(
        saver = listSaver<SnapshotStateList<String>, String>(
            save = { it.toList() },
            restore = { it.toMutableStateList() }
        )
    ) { initial.toMutableStateList() }
Enter fullscreen mode Exit fullscreen mode

In most apps, the better answer is to keep the state in a ViewModel (with SavedStateHandle for what must survive process death) and not in remember at all.

4.4 Index-based removal in a LazyColumn

// ❌ `index` was captured when this item composed. After other removals it can be stale.
LazyColumn {
    itemsIndexed(items) { index, item ->
        Row(Modifier.clickable { items.removeAt(index) }) { Text(item.title) }
    }
}

// ✅ Stable key, remove by identity
LazyColumn {
    items(items, key = { it.id }) { item ->
        Row(Modifier
            .animateItem()   // Foundation 1.7+; on older versions use animateItemPlacement() with @OptIn(ExperimentalFoundationApi::class)
            .clickable { items.removeAll { it.id == item.id } }
        ) { Text(item.title) }
    }
}
Enter fullscreen mode Exit fullscreen mode

Without keys, Lazy layouts match items by position, so state like remembered text fields can jump to the wrong row after a removal. With keys, state follows the item.

4.5 Two more worth knowing

Read-modify-write races. SnapshotStateList is snapshot-safe, but this is still a lost-update bug if two coroutines run it:

state.value = state.value + newItem   // read, then write: not atomic
Enter fullscreen mode Exit fullscreen mode

Fix it by funneling updates through one dispatcher, using Snapshot.withMutableSnapshot { ... } to batch related writes, or moving the source of truth to MutableStateFlow and using update { }.

Writing state during composition. Changing a state after it has already been read in the same composition is a “backwards write,” and it can cause a recomposition loop. Writes belong in event handlers and effects. If a value is a pure function of other state, derive it:

val hasItems by remember { derivedStateOf { items.isNotEmpty() } }
Enter fullscreen mode Exit fullscreen mode

5. A decision guide

decision-guide

🔚 Closing thoughts

The mental model that prevents most of these bugs is short:

  1. Compose observes reads and writes of state objects, not changes to arbitrary objects. Mutating something it can’t see changes nothing on screen.
  2. Equality decides whether a write counts. Same value, no invalidation.
  3. Where you read decides what reruns. Defer reads as deep as the layout allows.
  4. Specialize and read through the specialized accessor. mutableIntStateOf only helps if you use .intValue.

If you only change one habit after reading this: stop putting MutableList inside mutableStateOf, and start exposing List<T> backed by a private SnapshotStateList or StateFlow.

🔐 Go Beyond Using Android — Master How It’s Actually Secured

If you’re building, testing, or breaking Android apps, understanding the security model beneath the SDK separates developers who follow best practices from those who truly understand why those practices exist. I highly recommend “Mastering Android Security: From Fundamentals to Advanced Practice.” It’s a comprehensive, architecture-first journey through app sandboxing, cryptography, network defense, reverse engineering, and exploitation — spanning everything from AndroidManifest.xml internals to TrustZone and the Verified Boot chain, with hands-on labs at every step.


Originally published on Medium.

Top comments (0)