Immutable UI State and Stability in Jetpack Compose

Quick answer: Model screen UI state with immutable values and replace it for each state transition. That makes ownership, testing, and change detection clear. Compose stability is a compiler/runtime optimization on top of that model—not a reason to add @Immutable everywhere. First measure a real recomposition problem, then use the compiler’s stability information to address the specific cause.

In Compose, immutable UI state is an architecture tool: the UI receives a snapshot of what to render and sends events to the owner. Stability is a separate contract that helps Compose decide whether an eligible composable can be skipped when its parent recomposes. The official stability guide defines a stable type as immutable or as a type for which Compose can know whether it changed.

Start with an immutable screen model

Use val properties and immutable item models. Update by creating a new state value instead of changing a field behind the UI’s back.

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

data class ProfileUiState(
    val name: String = "",
    val isSaving: Boolean = false,
    val errorMessage: String? = null,
)

class ProfileViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(ProfileUiState())
    val uiState = _uiState.asStateFlow()

    fun onNameChanged(name: String) {
        _uiState.update { state ->
            state.copy(name = name, errorMessage = null)
        }
    }
}

ProfileUiState has no mutable public properties. A new value represents every transition, so reducers and tests can compare complete before-and-after states without a child composable retaining a second mutable copy. For the composable boundary, state hoisting keeps this state owner at the lowest common ancestor that needs it.

Immutability does not mean every value must be saved forever or held in a ViewModel. A private, short-lived interaction can still use local Compose state. The question is who needs to read and change it.

Why a data class alone is not always stable

This is an immutable-looking UI model, but its List property matters to Compose’s inference:

data class FeedUiState(
    val isLoading: Boolean = false,
    val posts: List<PostUi> = emptyList(),
)

The standard List, Set, and Map interfaces are always treated as unstable by Compose because the compiler cannot prove that an underlying collection will never be mutated. That does not make this model architecturally wrong. It simply means a composable that receives it might not gain stability-based skipping from that parameter alone.

Do not react by passing mutable collections or exposing a mutable var. Keep the UI model immutable for correctness, then optimize only if measurement shows that stability is relevant. The stability diagnosis guide recommends investigating stability when unnecessary or excessive recomposition is causing a performance problem.

Use persistent collections when stability is the proven bottleneck

The Compose compiler understands Kotlinx immutable collections. When a screen model has a collection parameter on a hot path, use its persistent types to make the immutability explicit to the compiler.

import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

@Immutable
data class FeedUiState(
    val isLoading: Boolean = false,
    val posts: ImmutableList<PostUi> = persistentListOf(),
)

This declaration is only valid if PostUi is also deeply immutable: its public properties, and the types of those properties, must uphold the same promise. The official fixes guide documents Compose support for Kotlinx immutable collections and notes that the library is alpha as of that guidance; evaluate its current release status before adopting it across an app.

@Immutable is an assertion to the compiler, not a deep-freeze operation. Do not annotate this class if any reachable public value can change without Compose being notified:

// Incorrect promise: tags can be changed through another reference.
@Immutable
data class BadPostUi(
    val tags: MutableList<String>,
)

An incorrect annotation can cause stale UI because it tells Compose it may make optimizations that your model does not actually support.

TermWhat it promisesTypical example
ImmutablePublic observable behavior never changes after constructionA data class made only of deeply immutable val properties
StableIf public behavior changes, Compose is notified; equality remains reliableMutableState<T> or a well-designed state holder
UnstableCompose cannot safely infer whether a value changedStandard collection interfaces or a model with mutable public fields

Compose-provided mutable snapshot types can be stable because the runtime observes their changes. That is different from ordinary mutable objects. For list ownership patterns, see managing lists and collections as Compose state.

Strong skipping changes the trade-off

Strong skipping makes restartable composables skippable even when they have unstable parameters. Android’s documentation says it is enabled by default with Kotlin 2.0.20 and later. Under this mode, Compose compares unstable parameters by instance identity and stable parameters by object equality.

That is useful, but it is not permission to mutate objects in place. If an unstable object is mutated while keeping the same instance, identity comparison cannot express that semantic change. Immutable replacement remains the clearest way to communicate a new UI state.

If a project uses an older compiler configuration, enable strong skipping only after checking the current compiler guidance for its Compose and Kotlin setup. Do not use stability annotations just to chase a “skippable” label.

Diagnose before changing models

Use a short loop when a screen is visibly slow:

  1. Reproduce the interaction with realistic data and profile it.
  2. Inspect recomposition and skip counts in Android Studio’s Layout Inspector.
  3. Identify the parameter or state read that causes the unwanted work.
  4. Fix the smallest verified cause, then measure again.

The Layout Inspector guidance documents its composition and skip counters. For an optimization that is often more appropriate than changing a whole model, use derivedStateOf only when a frequent input collapses into a less-frequent UI result.

Practical rules

  • Prefer immutable UI-state snapshots for screens, events for mutations, and one clear owner.
  • Treat @Immutable and @Stable as contracts that you must be able to prove, not performance decorations.
  • Do not convert every List to a persistent collection preemptively; standard read-only lists are still a sound default for ordinary UI state.
  • Keep an item’s stable ID separate from the collection’s stability. Dynamic LazyColumn data still needs a stable key; lazy list keys explains why.

FAQ

Does val make a state object immutable?

Only if the value it references is also immutable in practice. A val can still point to a mutable collection or mutable object. Check the whole public object graph, not just the top-level property declaration.

Should I add @Immutable to every UI data class?

No. Compose can infer many simple types, and an annotation is a promise. Add it only when the full contract is true and compiler evidence shows it helps a measured issue.

Is fewer recompositions always better?

No. Recomposition is normal and often cheap. Optimize the expensive, verified work rather than adding complexity solely to reduce a counter.