Lectures

Senior

State in Jetpack Compose

Snapshot state, State and MutableState, remember, observation scopes, collections, and state ownership in Compose.

jetpack-composestatesnapshot-staterecompositionstate-hoisting
On this page

State connects changing application data to declarative UI. Separate snapshot observation, Composition lifetime, read dependencies, mutation policy, collections, and architectural ownership. All supplied examples remain read-only Android/Compose fragments; none was compiled or executed during preparation.

Learning objectives:

  • distinguish observation, retention, and property delegation;
  • trace reads, writes, invalidation, and recomposition;
  • repair mutable collection/model update bugs;
  • expose immutable state and events through a clear owner.

Theory

State describes declarative UI

A traditional View implementation mutates widgets with setters. Compose describes UI = f(state): a composable says what the UI should be for the current value. When observed state changes, Compose can execute the necessary description again; the caller does not find a Text widget and mutate it.

textView.text = "Hello"
progressBar.isVisible = true
button.isEnabled = false
UI = f(state)
@Composable
fun Counter(count: Int) {
    Text("Count: $count")
}

Official reference: Thinking in Compose.

Why an ordinary var fails

There are two separate failures. A normal Kotlin assignment is not observable snapshot state, and executing the composable again runs its initializer again. A correct state mechanism must provide both observation and the required lifetime.

@Composable
fun Counter() {
    var count = 0

    Button(
        onClick = {
            count++
        }
    ) {
        Text("Count: $count")
    }
}
count = 0
   ↓
count++
   ↓
count = 1
var count: Int
var count = 0

State<T> and MutableState<T>

State<T> exposes a read-only observable value. MutableState<T> adds a writable value. Reading a State interface does not grant write authority; mutability belongs to the state owner. MutableState is an observable container, not merely an Int with special UI behavior.

interface State<out T> {
    val value: T
}
State<T>
┌───────────────┐
│ value: T      │
└───────────────┘
val state: State<Int>
val count = state.value
interface MutableState<T> : State<T> {
    override var value: T
}
val count: MutableState<Int> = mutableStateOf(0)
count.value++

Official reference: State and Jetpack Compose.

mutableStateOf and snapshot state

A MutableState participates in the Snapshot State System. Reading its value in an observed context can register a dependency; writing a meaningful new value can invalidate the reader, after which recomposition may be scheduled. This is more precise than saying a special variable redraws the whole screen.

state.value
state.value = newValue
Composition

    ↓ READ

MutableState<Int>
      value = 10

         ↓ WRITE

      value = 11

         ↓

Compose observes:
"The state has changed,
which was read by UI"

         ↓

invalidate

         ↓

recomposition

Official reference: Compose runtime API.

Observation alone is insufficient

Calling mutableStateOf(0) directly in a composable creates a new object whenever that call executes again. It solves observation but not retention: the new state starts at zero. The following examples deliberately demonstrate the problem rather than a recommended implementation.

@Composable
fun Counter() {
    val count = mutableStateOf(0)

    Button(
        onClick = {
            count.value++
        }
    ) {
        Text("Count: ${count.value}")
    }
}
val count = mutableStateOf(0)
Composition #1

MutableState
value = 0

click

value = 1

↓ recomposition

Composition #2

NEW MutableState
value = 0

remember supplies Composition lifetime

Initial composition runs the calculation and retains the MutableState object. After an event increments it, recomposition retrieves that same object with its new value. mutableStateOf supplies observation; remember supplies lifetime at a Composition position. These responsibilities are independent.

@Composable
fun Counter() {
    val count = remember {
        mutableStateOf(0)
    }

    Button(
        onClick = {
            count.value++
        }
    ) {
        Text("Count: ${count.value}")
    }
}
remember {
    mutableStateOf(0)
}
MutableState(value = 0)
0 → 1
MutableState(value = 1)
mutableStateOf
       ↓
makes the value observable

remember
       ↓
retains the object between recompositions

Official reference: State and Jetpack Compose.

Where remember retains a value

The Slot Table illustration is a simplified mental model of a retained value associated with a matching Composition position. A remembered value is not found by its global Kotlin variable name, and remember is not an application-wide cache.

@Composable
fun Screen() {
    val count = remember { mutableStateOf(0) }

    Text("Hello")

    Button(...) {
        ...
    }
}
Slot Table

Screen
│
├── remember
│      └── MutableState(0)
│
├── Text
│
└── Button

The limits of remember

The object is retained while its composable position remains in Composition. Leaving Composition forgets the value; returning later can create a new value. remember survives recomposition but does not itself survive navigation, configuration change, or process death. Choose a lifetime mechanism according to the actual requirement.

remember {
    ExpensiveObject()
}
recomposition
recomposition
recomposition
        ↓
the object is retained
Composition

Screen
 └── UserCard
      └── remember → Object

       ↓

UserCard leaves Composition

       ↓

the remembered value is forgotten

Official reference: Save UI state in Compose.

Composition and recomposition

Initial Composition runs composables to construct the UI description. Recomposition re-executes necessary work within an existing Composition after invalidation. These are related stages, but recomposition is not synonymous with a complete redraw or execution of every child.

Composable functions
       ↓
Composition
       ↓
UI structure
State changed
     ↓
scope invalidated
     ↓
Recomposition
recomposition ✅
leaving Composition ❌

How Compose finds readers

Reading state while describing UI records a dependency between the state and a restart scope. A meaningful write lets Compose find affected readers and invalidate their scopes. Holding or passing a State object without observing its value is not itself that dependency.

@Composable
fun Screen() {
    val count = remember {
        mutableStateOf(0)
    }

    Header()

    Counter(count.value)

    Footer()
}
count.value
MutableState(count)
        │
        │ read
        ▼
   Counter scope
count.value++
count changed

Who read it?

Counter scope

↓

invalidate Counter scope

Restart scopes and selective work

In Screen { Counter(count.value) }, the value is read in Screen before Counter receives a plain Int. The write invalidates the observing Screen scope; subsequent child work can be skipped according to compiler/runtime conditions. The original simplified illustration must not be read as a guarantee that Counter alone is invalidated. A notifications input can similarly allow unrelated UI work to be skipped.

@Composable
fun ProfileScreen(
    user: User,
    notifications: Int,
) {
    Header(user)

    NotificationBadge(notifications)

    Footer()
}
ProfileScreen
│
├── Header
│
├── NotificationBadge  ← INVALIDATED
│
└── Footer
recomposition ≠ redraw of the whole screen
recomposition ≠ required execution of
all child composables

Official reference: Compose phases and state reads.

Where a dependency is registered

Reading .value in Composition participates in the UI observation. Reading it later inside a click callback is useful for that event, but the callback read alone does not subscribe Screen to recomposition for each state change. Ask where and when the read happens, rather than only where the State object was created.

val state: State<Int>
@Composable
fun Screen(state: State<Int>) {
    Text("${state.value}")
}
@Composable
fun Screen(state: State<Int>) {
    Button(
        onClick = {
            println(state.value)
        }
    ) {
        Text("Click")
    }
}

Why snapshots are needed

Compose needs consistent versions of related values. The snapshot model covers reads, writes, change tracking, and applying changes between versions; it is more than a simple value-changed callback. The small version diagram below illustrates the idea without claiming to describe every runtime implementation detail.

State:

A = 10
B = 20

Snapshot #1
A = 10
B = 20

changes:

A = 11
B = 21

Snapshot #2
A = 11
B = 21
value changed → callback()

Equivalent assignment and mutation policy

The default mutation policy uses value equivalence. Assigning an equivalent old and new value need not be a meaningful change, so scopes need not be invalidated. Every .value assignment does not necessarily cause recomposition. A meaningful snapshot-state change can invalidate scopes that observed it, after which Compose may recompose them.

val count = mutableStateOf(10)

count.value = 10
old = 10
new = 10

equivalent?

YES

↓

no meaningful change

Observable collections or immutable replacement

Remembering a normal MutableList preserves its object but does not observe its mutations. Either use a snapshot-aware collection such as mutableStateListOf, or put an immutable List in observable state and replace the outer value. The two alternatives express different ownership choices.

val users = remember {
    mutableListOf<User>()
}
users.add(user)
val users = remember {
    mutableStateListOf<User>()
}
users.add(user)
var users by remember {
    mutableStateOf(emptyList<User>())
}
users = users + user
old List
   ↓
new List

Official reference: State and Jetpack Compose.

Mutable members inside a data class

Changing a MutableList inside UiState does not write a new outer MutableState value. The data can change while UI remains stale because the outer object is still the same. Prefer an immutable model and use copy with a new list; that makes the old-state-to-new-state transition explicit.

data class UiState(
    val users: MutableList<User>
)
var state by mutableStateOf(
    UiState(mutableListOf())
)
state.users.add(user)
state.value
       │
       └── same UiState
data class UiState(
    val users: List<User>
)
state = state.copy(
    users = state.users + user
)
old UiState
      ↓
event
      ↓
new UiState
      ↓
UI

State ownership and events

Passing a MutableState gives a consumer both read and write authority, allowing arbitrary changes. Prefer immutable current data plus event callbacks such as count and onIncrement. This is unidirectional data flow: state to UI, event to owner, then a new state. Hoist state to the lowest common ancestor of its readers and writers.

fun SomeComponent(
    state: MutableState<Int>
)
READ + WRITE
state.value = 500
@Composable
fun Counter(
    count: Int,
    onIncrement: () -> Unit,
)
        State
          │
          ▼
         UI
          │
          ▼
        Event
          │
          ▼
      State owner
          │
          └──────→ new State

Official reference: State hoisting.

.value and by

Both forms ultimately read and write the same state value. by is Kotlin property delegation; Compose supplies getValue and setValue operators. It changes syntax, not retention or recomposition semantics. Observation, lifetime, and delegation remain three independent mechanisms.

val count = remember {
    mutableStateOf(0)
}

count.value++
var count by remember {
    mutableStateOf(0)
}
count++
operator fun <T> State<T>.getValue(...)
var count by state
count
state.value
count = 10
state.value = 10
by
 ↓
Kotlin delegation
 ↓
getValue / setValue
 ↓
State.value
 ↓
Snapshot read/write

Official reference: Kotlin delegated properties.

The central mental model

A meaningful snapshot-state write can invalidate restart scopes that observed the value. Recomposition can then run the required work. mutableStateOf supplies observation and remember supplies Composition lifetime: two orthogonal responsibilities, rather than one magical mechanism.

mutableStateOf → redraws the screen
              ┌─────────────┐
              │ MutableState│
              └──────┬──────┘
                     │
                  READ during
                  composition
                     │
                     ▼
              ┌─────────────┐
              │ restart     │
              │ scope       │
              └─────────────┘

                     ▲
                     │
                   WRITE
                     │

              state changes

                     ↓

                 invalidate

                     ↓

               recomposition
remember
   │
   ▼
Slot Table / Composition
   │
   ▼
retain a value
between recompositions
mutableStateOf → observation

remember       → lifetime

Practice

Trace a counter from initial composition to update

Purpose: follow observation and retention through one update. Initial composition executes Counter and retains MutableState(0). The UI read records its dependency. Clicking reads zero and writes one. Invalidation marks the reader scope. Recomposition retrieves the same retained object with value one and describes Count: 1. Trace these steps in the supplied fragments; they were reviewed, not compiled or executed.

@Composable
fun Counter() {
    var count by remember {
        mutableStateOf(0)
    }

    Button(
        onClick = {
            count++
        }
    ) {
        Text("Count: $count")
    }
}
remember { mutableStateOf(0) }
MutableState(0)
Text("Count: $count")
count++
read value
0

↓

write value
1
INVALID
remember {
    mutableStateOf(0)
}
MutableState(1)
Text("Count: $count")
Count: 1

Why is this counter wrong?

@Composable
fun Counter() {
    var count = 0

    Button(onClick = { count++ }) {
        Text("$count")
    }
}

Compose does not observe the ordinary var, and its local value is not retained between recompositions.

What is wrong with this observable counter?

@Composable
fun Counter() {
    val count = mutableStateOf(0)

    Button(onClick = { count.value++ }) {
        Text("${count.value}")
    }
}

The state is observable, but a new MutableState(0) is created during recomposition. Add remember for the required lifetime.

What do these mechanisms independently contribute?

var count by remember {
    mutableStateOf(0)
}

mutableStateOf supplies snapshot observation, remember supplies Composition lifetime, and by supplies Kotlin property delegation.

Does merely passing MutableState establish a dependency?

No. An observed read matters; merely passing the object without reading its value during Composition does not create that UI dependency.

Why can this mutable model fail to update UI?

var state by mutableStateOf(
    UiState(users = mutableListOf())
)

state.users += newUser

The inner MutableList changes without writing a new outer MutableState<UiState> value. Replace the outer immutable state instead.

Which description is more precise?

A meaningful snapshot-state change invalidates observing composition scopes; Compose may then recompose those scopes. It is less accurate to say that MutableState redraws the screen.

Further reading

Suggested next topics

The supplied outline proceeds to remember keys, rememberSaveable, retain, and ViewModel lifetime, including recomposition, leaving Composition, key changes, navigation, configuration change, and process death. Later topics include state hoisting, stateful/stateless composables, derivedStateOf, state-holder placement, and application architecture. This is the source outline; those additional lectures are not yet published.

remember
remember(key)
      ↓
rememberSaveable
      ↓
retain
      ↓
ViewModel