Middle+
Jetpack Compose Mental Model
Declarative UI, Composition, recomposition, state observation, positional identity, and remember in Jetpack Compose.
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
Official reference: Compose performance best practices.
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.
- How does declarative UI differ from imperative UI?
- What does
@Composableactually mean? - What is a Composition?
- What is recomposition?
- How does recomposition differ from redraw?
- What are the main Compose phases?
- Why does ordinary
var count = 0not update UI? - What does
rememberdo? - What does
mutableStateOfdo? - Why is
remember { mutableListOf() }insufficient for automatic UI updates? - Why are side effects unsafe directly in a composable body?
- Why can two calls of one composable have independent remembered values?
- What does invalidation mean?
- Why is not every recomposition a performance problem?
- 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.