Jetpack Compose: Core Concepts Deep Dive (Part 1/2)
A detailed learning-in-public deep dive on Core Concepts in Jetpack Compose, covering concept batch 1/2.
Table of contents
- Context and Scope
- Conceptual Model & Analogy
- Deep Dive
- 1) @Composable — the magic label
- 2) Declarative UI — describe, don’t mutate
- 3) Composition Tree — the invisible UI family tree
- 4) Recomposition — precise, minimal refreshes
- 5) Idempotency — same inputs, same UI
- 6) Composable Lifecycle — entering, staying, leaving
- 7) Positional Memoization — “remember” by where it lives
- 8) Pure Composables — no hidden side effects
- Implementation Patterns
- Baseline Example (all eight concepts in one small flow)
- Production-Grade Example (state hoisting, effects, keys, and purity)
- Common Pitfalls and Tradeoffs
- Sources & Further Reading
- Check Your Work
- Hands-on Exercise
- Brain Teaser
Context and Scope
Run date: 2026-03-04. This is the first deep-dive in the Jetpack Compose Core Concepts track (Fundamental level). We’ll cover eight must-know ideas you’ll use every day when building Compose UIs:
- @Composable
- Declarative UI
- Composition Tree
- Recomposition
- Idempotency
- Composable Lifecycle
- Positional Memoization
- Pure Composables
You’ll get practical patterns, pitfalls to avoid, and migration tips from imperative Views to Compose. References point to current official docs and recent runtime guidance (2023–2025).
Conceptual Model & Analogy
Think of your UI as a live score performed from sheet music:
- The score is your Composable functions. They describe what to play, not how fingers move.
- The orchestra is the Compose runtime. It builds a seating chart (composition tree), cues sections to re-play when a bar changes (recomposition), and remembers instrument setups by seat position (positional memoization).
- Each performance must sound the same if the notes and tempo are unchanged (idempotency).
- Musicians join, perform, and leave the stage (composable lifecycle) as the piece evolves with interactions and state.
This mental model keeps your code declarative and predictable. (developer.android.com)
Deep Dive
1) @Composable — the magic label
Annotate a Kotlin function with @Composable to tell the Compose compiler and runtime “this function emits UI.” Only composables can call other composables. Without @Composable, Compose won’t track, schedule, or render the function’s output. This annotation lets the compiler transform your function, thread through the runtime “Composer,” and participate in recomposition. (developer.android.com)
What it implies:
- Can only be invoked from another composable or a composition root (e.g., setContent).
- The function may run many times; it must be fast and side-effect free (see Idempotency, Pure Composables). (developer.android.com)
2) Declarative UI — describe, don’t mutate
In Compose you describe what the UI should look like for a given state. When state changes, you call the same composables with new data; Compose figures out the minimal updates. You don’t imperatively find views or set properties. This flips the mental model from “do this to the view” to “here’s the UI for this state.” (developer.android.com)
Why it matters:
- Easier reasoning: inputs → UI.
- Fewer bugs from manual widget mutations.
- Natural testability and better separation of concerns.
3) Composition Tree — the invisible UI family tree
When composition runs, the runtime executes your composables and produces a tree of nodes that represent your UI. This internal structure is not your data model; it’s runtime bookkeeping used to apply updates efficiently and host remembered values. Understanding that there’s a tree helps explain things like list item state, keys, and why moving code changes identity. (developer.android.com)
4) Recomposition — precise, minimal refreshes
When observable state read by a composable changes, Compose schedules recomposition for only the affected scopes. Recomposition re-executes those functions, computes the new UI description, and applies changes to the composition tree. Proper state scoping keeps recompositions small and fast. (developer.android.com)
Signals that trigger recomposition:
- Reads of State
, collectAsState of Flow/LiveData, derivedStateOf, etc. - Parameter changes to composables.
5) Idempotency — same inputs, same UI
Composable functions should be idempotent: running them multiple times with the same parameters and the same observed state must produce the same UI. Avoid hidden actions like logging, toasts, or network calls during composition; use explicit Effect APIs instead. Idempotency is what makes frequent recomposition safe. (developer.android.com)
6) Composable Lifecycle — entering, staying, leaving
Every composable instance goes through:
- Enter: first time added to the composition (initial remember blocks run).
- Stay: recomposed zero or more times as state changes.
- Leave: removed from the composition (remembered values are forgotten; DisposableEffect onDispose runs). Understanding this is key for resource management and avoiding leaks. (developer.android.com)
7) Positional Memoization — “remember” by where it lives
remember stores a value tied to the composable’s position in the composition tree. If that position changes (because you restructured code, a branch toggled, or items moved), Compose may treat it as a different instance and forget the old value—unless you provide stable keys (key(), Lazy list item keys) or appropriate remember keys. This is why “remember works by position” is so often mentioned. (android.googlesource.com)
Extending lifespans:
- remember: survives recompositions only.
- rememberSaveable / rememberSerializable: also survives Activity recreation and process death (via Bundle or kotlinx.serialization).
- retain (Compose 1.10+): survives configuration changes without serialization for non-savable objects, but not process death. Use carefully to avoid leaks. (developer.android.com)
8) Pure Composables — no hidden side effects
A “pure” composable reads inputs and emits UI. It does not:
- Start coroutines
- Log, show toasts, or hit the network
- Register listeners or hold system resources
When you need side effects, use the Effect APIs (LaunchedEffect, DisposableEffect, SideEffect, rememberUpdatedState, produceState). This keeps composition safe and predictable. (developer.android.com)
Implementation Patterns
-
Split stateful and stateless composables
- Stateless: draw-only (pure) with parameters and callbacks.
- Stateful wrappers: own/hoist State and call the stateless version.
-
Hoist state
- Own user-editable state at the lowest common parent that needs to read/write it. Pass values down and callbacks up. This reduces unnecessary recompositions in deep children. (developer.android.com)
-
Key your state
- When using remember inside conditionals, loops, or lists, pass stable keys or use key() to preserve identity when items move. For lists, always supply item keys to LazyColumn for stable item identity. (developer.android.com)
-
Choose the right memoization lifespan
- remember for ephemeral UI objects (AnimationState, ScrollState).
- rememberSaveable/rememberSerializable for user input that must survive process death (text, toggles, selection).
- retain for non-serializable, config-change–resilient objects scoped to a composable (e.g., ExoPlayer), but avoid retaining framework types like Activity/Context directly. (developer.android.com)
-
Keep composables idempotent
- Compute derived UI state with derivedStateOf or in ViewModels.
- Use Effect APIs for work tied to lifecycle or keys (LaunchedEffect for suspend work; DisposableEffect for register/unregister; rememberUpdatedState to keep the latest lambda without restarting an effect). (developer.android.com)
-
Measure and optimize recomposition
- Use the Compose Compiler Gradle plugin reports and stability configuration to understand skippability and stability. Target making hot-path composables skippable with stable parameters. (developer.android.com)
Baseline Example (all eight concepts in one small flow)
@Composable // @Composable: tells the compiler/runtime this draws UI
fun CounterCard(
title: String, // Declarative inputs
modifier: Modifier = Modifier
) {
// Positional memoization: value tied to this call site
var count by rememberSaveable { mutableStateOf(0) } // survives process death
// Pure composable subtree: no side effects; just render from state
Card(modifier) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text("Count: $count")
Spacer(Modifier.height(8.dp))
Button(onClick = { count++ }) { Text("Increment") }
}
}
}
What’s happening:
- Declarative UI: UI reflects title and count.
- Composition Tree: Card → Column → Text/Button nodes are tracked by the runtime.
- Recomposition: Clicking updates State, only this subtree recomposes.
- Idempotency: Same title and count produce the same UI.
- Lifecycle: When CounterCard leaves, its remembered state is forgotten; with rememberSaveable it’s restored on recreation.
- Positional Memoization: The count belongs to this exact call site.
- Pure Composables: No hidden side effects in the UI subtree. (developer.android.com)
Production-Grade Example (state hoisting, effects, keys, and purity)
// Domain
data class City(val id: String, val name: String)
@Composable
fun CitySearchScreen(
// Pure function to fetch cities; supply from VM/use case.
// We keep it as a suspend lambda to show rememberUpdatedState in action.
searchCities: suspend (query: String) -> List<City>,
modifier: Modifier = Modifier
) {
// UI state owned here (stateful wrapper)
var query by rememberSaveable { mutableStateOf("") } // survives process death
var results by remember { mutableStateOf(emptyList<City>()) } // ephemeral list
var loading by remember { mutableStateOf(false) }
val latestSearcher by rememberUpdatedState(searchCities) // keep latest lambda without restarting effects
// Side effect: debounce queries; do not run during composition
LaunchedEffect(query) { // restarts when query changes
loading = true
val debounced = query.trim()
if (debounced.isNotEmpty()) {
// snapshotFlow would be fine if observing State in a stream
results = try { latestSearcher(debounced) } catch (_: Throwable) { emptyList() }
} else {
results = emptyList()
}
loading = false
}
// Pure presentational subtree
Column(modifier.padding(16.dp)) {
SearchBar(
text = query,
onTextChange = { query = it }, // state hoisting
placeholder = "Search cities…"
)
Spacer(Modifier.height(8.dp))
if (loading) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
LazyColumn {
// Always provide stable keys for stable identity
items(items = results, key = { it.id }) { city ->
CityRow(city = city, onClick = { /* delegate to VM / nav */ })
}
}
}
}
@Composable
private fun SearchBar(
text: String,
onTextChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier
) {
// Pure composable: draws from inputs only; no effects here
TextField(
value = text,
onValueChange = onTextChange,
modifier = modifier.fillMaxWidth(),
placeholder = { Text(placeholder) },
singleLine = true
)
}
@Composable
private fun CityRow(city: City, onClick: () -> Unit, modifier: Modifier = Modifier) {
// Pure composable row
Row(
modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp)
) {
Text(city.name, style = MaterialTheme.typography.bodyLarge)
}
}
Callouts:
- Pure Composables: SearchBar and CityRow are render-only.
- Effects isolated: LaunchedEffect handles debounce + IO; rememberUpdatedState prevents effect restarts if the searcher instance changes.
- Recomposition stays focused: Changing query mostly affects the search bar and list content.
- Positional Memoization & keys: remember’d states are tied to positions; item keys keep row identity stable when results reorder. (developer.android.com)
Common Pitfalls and Tradeoffs
-
Side effects inside composition
- Pitfall: Launching coroutines, logging, or starting animations directly in the body of a composable.
- Fix: Use LaunchedEffect/DisposableEffect/SideEffect; keep UI pure. (developer.android.com)
-
“remember in a branch” bugs
- Pitfall: Initializing remember inside if/for without keys; when the branch toggles or items move, state is lost or mismatched.
- Fix: Lift remember out, add stable keys (remember(key)), or use key() around the block. (android.googlesource.com)
-
Over-recomposition from broad state reads
- Pitfall: Reading many unrelated states in a single composable makes it recompose too often.
- Fix: Split into smaller composables, derive minimal UI state with derivedStateOf, and pass only what’s needed. (developer.android.com)
-
Choosing the wrong state lifespan
- Pitfall: Using remember for user input (lost on process death) or using rememberSaveable for massive objects (serialization overhead).
- Fix: Use rememberSaveable/rememberSerializable for user-facing state; use retain for non-serializable, config-change–resilient objects; otherwise prefer remember. (developer.android.com)
-
Ignoring stability/skippability
- Pitfall: Unstable parameters force recomposition.
- Fix: Prefer stable types, immutable data where practical, and consult compiler reports via the Compose Compiler Gradle plugin to spot hot paths. Don’t chase “100% stable”—optimize where it matters. (developer.android.com)
-
Confusing lifecycle scopes
- Pitfall: Holding on to Activity/Context-heavy objects in remember or retain, causing leaks.
- Fix: Use applicationContext when needed; prefer ViewModel for long-lived logic; use DisposableEffect for registering/unregistering listeners. (developer.android.com)
Sources & Further Reading
- Thinking in Compose (declarative model, idempotent guidance). (developer.android.com)
- Lifecycle of composables (enter/stay/leave, side-effect cleanup). (developer.android.com)
- Compose Phases (how composition builds the tree). (developer.android.com)
- State and Jetpack Compose (state, hoisting, remember/rememberSaveable). (developer.android.com)
- State lifespans: remember vs rememberSaveable vs rememberSerializable vs retain (1.10+). (developer.android.com)
- How Compose Works (Composer, positional memoization details). (android.googlesource.com)
- Side-effects in Compose (LaunchedEffect, DisposableEffect, rememberUpdatedState, produceState). (developer.android.com)
- Compose Compiler Gradle plugin (reports, stability configuration). (developer.android.com)
Check Your Work
Hands-on Exercise
- Build a small “Favorites” list:
- A pure composable FavoriteRow(item: Item, selected: Boolean, onToggle: () -> Unit).
- A stateful wrapper that:
- Keeps a Set
of selected IDs with rememberSaveable. - Displays items in LazyColumn with keys = { it.id }.
- Uses LaunchedEffect to persist selections to a repository when they change (simulate with delay). Verify:
- Keeps a Set
- Toggling a row only recomposes that row.
- Rotating the device restores selections.
- Reordering the list preserves which rows are selected.
- Refactor a screen from your codebase:
- Extract at least two pure presentational composables.
- Move all side effects to LaunchedEffect/DisposableEffect blocks in a small “coordinator” composable.
- Run Compose Compiler reports and note any change in skippability on hot-path composables.
Brain Teaser
- Suppose you have:
- remember { Animatable(0f) } inside an if (expanded) branch, and expanded toggles rapidly.
- Sometimes the animation resets unexpectedly. Explain precisely why this happens in terms of positional memoization and the composition tree. Then provide two fixes:
- One using remember(expanded) with a seeded value.
- One lifting the remember out of the branch and animating toward the new target. Which fix better preserves state across rapid toggles, and why?
References
- developer.android.com/develop/ui/compose/mental-model
- developer.android.com/develop/ui/compose/lifecycle
- developer.android.com/develop/ui/compose/phases
- developer.android.com/develop/ui/compose/state
- developer.android.com/develop/ui/compose/state-lifespans
- android.googlesource.com/platform/frameworks/support/%2B/HEAD/compose/runtime/design/how-compose-works.md
- developer.android.com/develop/ui/compose/side-effects
- developer.android.com/develop/ui/compose/compiler
Share
More to explore
Keep exploring
3/16/2026
Jetpack Compose: Core Concepts Refresher
A spaced-repetition refresher on Core Concepts in Jetpack Compose, focused on practical implementation details and updates.
3/7/2026
Jetpack Compose: Core Concepts Refresher
A spaced-repetition refresher on Core Concepts in Jetpack Compose, focused on practical implementation details and updates.
3/6/2026
Jetpack Compose: Core Concepts Deep Dive (Part 2/2)
A detailed learning-in-public deep dive on Core Concepts in Jetpack Compose, covering concept batch 2/2.
Previous
Weekly Engineering Mastery Quiz (2026-03-02 to 2026-03-06)
Next