Lectures

Middle+

Jetpack Compose Mental Model

Declarative UI, Composition, recomposition, state observation, positional identity, and remember in Jetpack Compose.

jetpack-composecompositionrecompositionstate
On this page

Build a mental model of how Compose describes UI before studying state, effects, performance, stability, and architecture. The supplied examples remain read-only Android/Compose fragments and were not compiled or executed during preparation. Diagram labels are translated to English; Kotlin examples retain their supplied semantics.

Learning objectives:

  • distinguish Composition, recomposition, Layout, and Draw;
  • explain state observation and retention independently;
  • trace invalidation and positional identity;
  • identify why performance decisions need measurement.

Theory

Imperative and declarative UI

A View-based screen is often updated imperatively: application code finds a concrete widget and tells it which property to change. It describes a transition through commands: show a progress bar, hide content, and set text. Compose instead describes the UI required for a state value. The useful approximation is UI = f(state): for state A the composable describes UI A; for state B it describes UI B. Compose manages the transition. Pass state down, send events up, and describe the resulting UI.

data class ScreenState(
    val isLoading: Boolean,
    val username: String?
)
fun render(state: ScreenState) {
    if (state.isLoading) {
        progressBar.visibility = View.VISIBLE
        content.visibility = View.GONE
    } else {
        progressBar.visibility = View.GONE
        content.visibility = View.VISIBLE
        usernameText.text = state.username
    }
}
old UI
   ↓
a sequence of mutation commands
   ↓
new UI
@Composable
fun Screen(state: ScreenState) {
    if (state.isLoading) {
        CircularProgressIndicator()
    } else {
        Text(state.username.orEmpty())
    }
}
UI = f(state)
State A
   ↓
@Composable
   ↓
UI A

State B
   ↓
@Composable
   ↓
UI B

Official reference: Thinking in Compose.

What @Composable means

It is misleading to call a composable a function that creates a View. The Compose compiler transforms composable calls so that the runtime can track their position in a Composition, remember values, observe state dependencies, re-execute necessary work, skip unnecessary work, and manage lifecycle. The compiler-expanded signature below is deliberately conceptual, not a public source-level signature. @Composable identifies a function integrated with the Compose compiler and runtime, rather than an ordinary call-create-forget function.

@Composable
fun Greeting(name: String) {
    Text("Hello $name")
}
@Composable
fun Greeting(name: String)
fun Greeting(
    name: String,
    composer: Composer,
    changed: Int
)
call → create UI → forget

Official reference: Compose runtime API.

Composition and initial composition

On its first execution Compose creates a Composition: a runtime structure that records composable calls and associated state. It may be pictured as a tree of calls, but it is not an Android View hierarchy. This first construction is initial composition. A composable instance enters the Composition, can be recomposed zero or more times, and eventually leaves it.

@Composable
fun UserScreen(user: User) {
    Column {
        Text(user.name)

        Button(onClick = {}) {
            Text("Follow")
        }
    }
}
UserScreen
│
└── Column
    │
    ├── Text
    │   └── "Konsti"
    │
    └── Button
        └── Text
            └── "Follow"
Initial Composition

Official reference: Composable lifecycle.

State changes and recomposition

Initially the counter is zero. A click changes observable MutableState from zero to one. Compose invalidates the affected reader scope, schedules work, and re-executes necessary code: recomposition. Invalidation and the subsequent execution are separate steps; changing state does not synchronously rebuild the entire screen.

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

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

Counter()
   ↓
Button
   ↓
Text("Count: 0")
count++
MutableState
0 → 1
state changed
     ↓
affected scope invalidated
     ↓
recomposition scheduled
     ↓
affected composable code executed again

Recomposition does not rebuild the whole screen

A state change can require re-executing only a relevant restart scope. Unchanged child work may be skipped. Skipping is an optimisation decision; code must remain correct whether it is skipped or re-executed. A parent recomposing does not imply that every child must recompose. The simplified tree below illustrates selective work rather than guaranteeing a particular compiler boundary.

state changed
↓
the whole screen rebuilt
@Composable
fun Screen() {
    var count by remember {
        mutableStateOf(0)
    }

    Column {
        Header()

        Counter(count)

        Footer()
    }
}
Header()   → SKIP
Counter()  → RECOMPOSE
Footer()   → SKIP

Composition, layout, and drawing

Composition answers what belongs in the UI. Layout answers how large elements are and where they are placed: parent constraints flow to children, children are measured, the parent chooses its size, and children are placed. Draw produces pixels, for example with drawRect or drawCircle. Recomposition is not synonymous with a complete redraw. A state change can require different phases depending on where the value is read.

1. Composition
      ↓
2. Layout
      ↓
3. Draw
if (loggedIn) {
    HomeScreen()
} else {
    LoginScreen()
}
parent constraints
      ↓
measure children
      ↓
choose own size
      ↓
place children
drawRect(...)
drawCircle(...)

Official reference: Compose phases.

Where state is read matters

Reading an animated offset while building a modifier records a composition dependency, so a change can involve Composition, Layout, and Draw. The lambda overload defers the read to Layout and may avoid Composition for that change. Read frequently changing state as late as the required work permits. Apply this after measuring an actual issue, rather than obscuring ordinary UI code as a speculative optimisation.

val offset by animateDpAsState(...)

Box(
    Modifier.offset(x = offset)
)
state changed
↓
Composition
↓
Layout
↓
Draw
Box(
    Modifier.offset {
        IntOffset(offset.roundToPx(), 0)
    }
)
state changed
↓
Layout
↓
Draw

Why an ordinary Kotlin variable does not update UI

The local counter has two independent problems. Compose does not observe arbitrary assignments to ordinary Kotlin variables. If the composable later executes again, the initializer also executes again and resets the local value. A UI value therefore needs both preservation between recompositions and notification of meaningful changes.

@Composable
fun Counter() {
    var count = 0

    Button(
        onClick = { count++ }
    ) {
        Text("$count")
    }
}
count++
var count = 0
1. retain a value between recompositions
2. notify Compose about changes

remember retains a value

At the first pass through a position in Composition, remember calculates and retains the result. At a later recomposition at the same position, it returns the retained result. An ordinary object initializer runs again; the remembered object is retrieved. Values are retained while that position remains in Composition, not globally or forever.

val something = remember {
    SomeObject()
}
@Composable
fun Example() {
    val object1 = SomeObject()

    val object2 = remember {
        SomeObject()
    }
}
object1 → new object
object2 → same remembered object
Composition
│
├── position #1
│
├── position #2
│   └── remembered SomeObject
│
└── position #3

Official reference: State and Jetpack Compose.

remember is not observable state

A remembered ordinary mutable list survives recomposition, but add does not itself request recomposition. remember answers where a value is retained; a normal MutableList is not observable Compose state. Retention and observation are different mechanisms.

val list = remember {
    mutableListOf<String>()
}
list.add("A")
remember
MutableList
remember
        ≠
observable state

mutableStateOf observes a value

remember retains the MutableState object between recompositions; mutableStateOf creates observable snapshot state. Reading and writing its value can participate in Compose observation. The delegated-property by syntax ultimately uses those same state reads and writes. It does not change lifetime or observation rules.

val count = remember {
    mutableStateOf(0)
}
MutableState<Int>
count.value = 1
var count by remember {
    mutableStateOf(0)
}
remember
   ↓
where to retain a value

mutableStateOf
   ↓
how to observe a change

Official reference: Kotlin delegated properties.

State reads establish dependencies

Reading snapshot state during an observed Compose context registers a dependency with the relevant scope. A meaningful write can invalidate its readers and schedule recomposition. For example, passing count.value to a child reads it in the caller before the child receives a plain value. Reading it only inside an event callback is different from reading it while composing UI. The Snapshot State System implements observation; these diagrams are a simplified model.

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

    Header()

    Text("$count")

    Footer()
}
Composition executes
        ↓
MutableState.value is read
        ↓
Compose records dependency
MutableState(count)
       │
       ▼
composition scope
count++
MutableState changed
       ↓
find readers
       ↓
invalidate scopes
       ↓
schedule recomposition

Composables and side effects

An analytics send directly in a composable body is unsafe: bodies can execute repeatedly, be skipped, or be abandoned during optimistic recomposition. Do not assume a body executes once when a screen opens. Uncontrolled database writes or repository requests also do not belong directly in the body. Compose provides LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, rememberUpdatedState, and produceState; their distinct lifecycle semantics require deliberate use.

@Composable
fun Screen() {
    analytics.send("Screen rendered")

    Text("Hello")
}
Composable is called once when the screen opens
analytics.send(...)
database.write(...)
repository.request(...)
LaunchedEffect
DisposableEffect
SideEffect
rememberCoroutineScope
rememberUpdatedState
produceState

Official reference: Compose side effects.

Positional identity

Compose associates remembered values and existing calls with their identity in Composition. A conditional composable can enter and leave Composition; when it leaves, its remembered values are forgotten. The runtime must distinguish values belonging to different calls and previous executions. This leads to call-site identity, key, Lazy layout keys, and positional memoisation.

@Composable
fun Screen(showCounter: Boolean) {
    if (showCounter) {
        Counter()
    }

    Footer()
}
Screen
│
├── Counter
│   └── remember(count)
│
└── Footer

Official reference: Composable identity.

remember belongs to a position, not a function

Two calls of the same counter function own separate remembered values: one can hold 3 while the other holds 12. remember belongs to a particular Composition position, not to the Kotlin function globally.

@Composable
fun Counter() {
    var count by remember {
        mutableStateOf(0)
    }
}
Column {
    Counter()
    Counter()
}
Column
├── Counter A
│   └── remember(count A)
│
└── Counter B
    └── remember(count B)
Counter A → 3
Counter B → 12

Recomposition scopes

A restart scope lets the runtime re-execute an invalidated area rather than a whole screen. Child calls may be skipped when their inputs and compiler/runtime conditions permit. The position of the actual state read determines the reader that is invalidated; the function name alone does not determine it.

@Composable
fun Screen() {
    Header()
    Counter()
    Footer()
}
Screen
│
├── Header   → skip
├── Counter  → recompose
└── Footer   → skip

Official reference: Compose phases and state reads.

Measure before optimising

Recomposition is designed to be relatively inexpensive. Frequent recompositions become a concern when they trigger expensive work across a large UI area. If measurement identifies filtering and sorting as a bottleneck, retain their result for the relevant input with remember(items). Do not introduce caching merely because recomposition occurred: first establish its cause and measure the cost.

frequent recompositions
×
expensive work
×
large UI area
@Composable
fun Screen(items: List<Item>) {
    val processed = items
        .filter { complicatedCheck(it) }
        .sortedBy { expensiveCalculation(it) }

    // ...
}
val processed = remember(items) {
    items
        .filter { complicatedCheck(it) }
        .sortedBy { expensiveCalculation(it) }
}

The central mental model

Compose reads observable state while describing UI, then proceeds through Layout and Draw. A meaningful state change is tracked by the snapshot system, invalidates readers, and can schedule recomposition. Once these relationships are clear, individual APIs fit into the model rather than appearing to be unrelated magic.

               STATE
                 │
                 │ read
                 ▼
        ┌─────────────────┐
        │   COMPOSITION   │
        │                 │
        │ @Composable     │
        │ functions       │
        └────────┬────────┘
                 │
                 ▼
              LAYOUT
                 │
                 ▼
               DRAW
                 │
                 ▼
                UI


Mutable State changes
        │
        ▼
Snapshot system
        │
        ▼
invalidate readers
        │
        ▼
recomposition
        │
        └──────────► Composition

What to take away

Compose is declarative: UI = f(state). @Composable is integrated with compiler and runtime, rather than a View factory. Composition retains call structure and associated information. Recomposition re-executes necessary work and need not rebuild a screen. Composition, Layout, and Draw are separate phases. remember retains values but does not make them observable; mutableStateOf creates observable state. Snapshot observation tracks readers and invalidates dependent work.

UI = f(state)
Composition → Layout → Draw
retains a value between recompositions
creates observable state

Practice

Trace a counter through its lifecycle

Purpose: connect retention, a state read, invalidation, and the visible update. Initial composition executes the screen and describes the zero counter. remember creates and retains its MutableState. The UI read records a dependency. Clicking increments the state from 0 to 1. Invalidation marks the reader scope; recomposition retrieves the retained object and describes the new count. Other work may be skipped. Trace each step in the following supplied examples. The examples are reviewed fragments, not execution evidence.

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

    Column {
        Text("Counter")

        Text("$count")

        Button(
            onClick = {
                count++
            }
        ) {
            Text("Increment")
        }
    }
}
CounterScreen()
       ↓
Column
├── Text("Counter")
├── Text("0")
└── Button
    └── Text("Increment")
Composition
└── remembered MutableState(0)
Text("$count")
MutableState(count)
        ↓
dependent composition scope
count++
0 → 1
count changed
↓
dependent scope invalidated
Text("0")
   ↓
Text("1")

Control questions

Answer from the surrounding theory and explain your reasoning before experimenting with code.

  1. How does declarative UI differ from imperative UI?
  2. What does @Composable actually mean?
  3. What is a Composition?
  4. What is recomposition?
  5. How does recomposition differ from redraw?
  6. What are the main Compose phases?
  7. Why does ordinary var count = 0 not update UI?
  8. What does remember do?
  9. What does mutableStateOf do?
  10. Why is remember { mutableListOf() } insufficient for automatic UI updates?
  11. Why are side effects unsafe directly in a composable body?
  12. Why can two calls of one composable have independent remembered values?
  13. What does invalidation mean?
  14. Why is not every recomposition a performance problem?
  15. Why does where state is read affect performance?

Further reading

Suggested next topics

The supplied course outline continues with state, recomposition scopes/skipping/identity/keys, snapshots, stability (@Stable, @Immutable, strong skipping), side effects, Layout and Modifier, Lazy layouts, ViewModel and Flow, lifecycle and state saving, performance, Compose API design, navigation/CompositionLocal/interoperability/testing, and interview drills. Only prepared lectures are published; these topics are an outline, not a promise of existing pages.