Managing Lists and Collections as Compose State

Quick answer: Keep collection UI state observable. The default, easy-to-reason-about choice is an immutable
Listheld inmutableStateOfand replaced whenever an item changes. For local, mutation-oriented UI state,mutableStateListOf()is also observable. Do not put a plainmutableListOf()orArrayListinside Compose state and mutate it in place: Compose cannot observe those mutations.
A list is not special just because it is displayed in a LazyColumn. Compose needs an observable state change to schedule recomposition, and a dynamic lazy list also needs stable item identity. The official Compose state guide specifically cautions against using ArrayList and mutableListOf() as state because their mutations are not observable.
Choose the state shape first
| Situation | Good default | Why |
|---|---|---|
Screen data from a repository or ViewModel | Immutable List<T> in a UI-state data class | One value represents the screen and updates are explicit |
| Small collection owned by one composable | mutableStateListOf<T>() | Add, remove, and indexed updates are observable snapshot changes |
| Selection or expanded IDs shared by siblings | Hoisted immutable Set<ID> or List<ID> | The parent remains the single source of truth |
| A mutable domain model | Map it to immutable UI items | Changing a field inside a non-observable object does not notify Compose |
For screen-level state, prefer exposing an immutable collection. The state-hoisting guidance recommends exposing immutable state and events from the state owner, then placing the owner at the lowest common ancestor that needs the state.
Use immutable list replacement for screen state
This pattern makes every list transition visible in one place. +, filterNot, and map return new read-only lists, so assigning their result updates the observable state holder.
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
data class TaskUi(
val id: String,
val title: String,
val isDone: Boolean = false,
)
@Composable
fun TaskScreen() {
var tasks by remember {
mutableStateOf(
listOf(
TaskUi(id = "write", title = "Write outline"),
TaskUi(id = "review", title = "Review draft"),
),
)
}
TaskList(
tasks = tasks,
onToggleDone = { id ->
tasks = tasks.map { task ->
if (task.id == id) task.copy(isDone = !task.isDone) else task
}
},
onDelete = { id ->
tasks = tasks.filterNot { it.id == id }
},
)
}The important operation is the assignment to tasks, not merely creating a list. mutableStateOf observes changes to its value; Compose can then recompose functions that read it. Kotlin’s + operator likewise returns a new list, which is useful for an add action such as tasks = tasks + TaskUi(...).
For data that survives configuration changes, do not assume remember is enough. See using rememberSaveable and custom savers for the saveable-state boundary. Larger data, repository-backed data, and business rules usually belong in a ViewModel, not in a saveable bundle.
Keep the rendered list stateless and keyed
Pass the collection down and send user events back up. This lets a parent, state holder, or ViewModel decide how to update it.
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
@Composable
fun TaskList(
tasks: List<TaskUi>,
onToggleDone: (String) -> Unit,
onDelete: (String) -> Unit,
) {
LazyColumn {
items(
items = tasks,
key = { task -> task.id },
) { task ->
TaskRow(
task = task,
onDoneClick = { onToggleDone(task.id) },
onDeleteClick = { onDelete(task.id) },
)
}
}
}Use an ID that stays stable as the list is filtered, sorted, inserted into, or reordered. The lazy lists documentation explains that keys let remembered item state move with its item rather than its old position. Lazy list keys covers that identity problem in more detail.
When mutableStateListOf is a better local fit
mutableStateListOf creates a SnapshotStateList. Its structural mutations are observable to Compose, so it can make concise local UI code when the collection does not need to be exposed as immutable screen state.
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
@Composable
fun RecentSearches(
onSearch: (String) -> Unit,
) {
val searches = remember {
mutableStateListOf("Compose state", "LazyColumn keys")
}
SearchHistory(
searches = searches,
onSearch = onSearch,
onRemove = { query -> searches.remove(query) },
onClear = { searches.clear() },
)
}This does not make every object inside the list observable. If TaskUi were a mutable class and code changed task.title in place, a list structural mutation would not necessarily occur. Prefer immutable item models and replace the item, or put the changing property in its own observable state. Compose’s stability guidance also notes that SnapshotStateList is a Compose-aware mutable type, while ordinary collection classes are treated conservatively for stability.
The stale-UI bug to avoid
This looks familiar but is not observable at the point Compose needs it:
@Composable
fun BrokenTasks() {
val tasks = remember { mutableListOf<TaskUi>() }
// This changes the list, but Compose was not told that observable state changed.
fun addTask(task: TaskUi) {
tasks.add(task)
}
}Wrapping the same mutable list in mutableStateOf does not fix in-place mutation either:
var tasks by remember { mutableStateOf(mutableListOf<TaskUi>()) }
tasks.add(TaskUi(id = "new", title = "New task")) // No new state value was assigned.Choose one of these repairs instead:
// Immutable value + replacement
tasks = tasks + TaskUi(id = "new", title = "New task")
// Or a snapshot-aware list
val tasks = remember { mutableStateListOf<TaskUi>() }
tasks.add(TaskUi(id = "new", title = "New task"))Practical boundaries and checks
- Do not keep a second mutable copy of a list in a child just to render it. Hoist shared state; state hoisting with real examples shows how to keep ownership clear.
- Treat each list update as a new UI-state transition. This makes loading, errors, filtering, and undo logic easier to test than scattered mutations.
- Give dynamic lazy-list items stable keys, but do not use the index as a substitute for a real ID.
- Avoid reaching for
derivedStateOfsimply to transform every list. Use it only when a derived value changes less often than its inputs or prevents work; see when to usederivedStateOf.
FAQ
Is List<T> truly immutable in Kotlin?
List<T> is read-only from the reference’s API; it does not by itself prove that no other reference can mutate the underlying object. In Compose UI state, construct and expose fresh read-only lists and avoid retaining a mutable backing list that another owner can change.
Should every collection use mutableStateListOf?
No. It is useful for local snapshot-aware mutations, but an immutable collection in a screen UI-state object is often clearer across a ViewModel boundary. Pick the representation that makes ownership and updates obvious.
Does replacing a list solve lazy-list identity?
No. A new list gives Compose an observable state update. A stable key tells a lazy layout which logical item moved or remained. Dynamic lists generally need both.