Chapter 3
Jetpack Compose
Recomposition · Stability · derivedStateOf · LazyColumn · State Hoisting
Ready to practise interactively?
Explore this chapter with quizzes, diagrams, and real-world examples in the full interactive experience.
How Recomposition Works
Recomposition = Compose re-running a composable function because its inputs changed. The goal is to recompose as little as possible, as rarely as possible.
- A State or StateFlow value that the composable reads changes
- The composable's parameters change and are considered unstable
- A parent recomposes and passes new (or unstable) values down
| Stability | What Compose does | Examples |
|---|---|---|
| Stable | Can skip recomposition if all parameters are equal (via equals()). Safe to skip. | Int, String, Boolean, @Stable class, @Immutable data class with val fields only |
| Unstable | Cannot skip. Recomposes every time parent recomposes, even if value is unchanged. | List, MutableList, classes from external libs, data classes with var fields |
Preventing Unnecessary Recomposition
Understanding and fixing recomposition issues is the most-tested Compose topic at Staff level.
| Problem | Fix |
|---|---|
| Passing List as parameter | Wrap in @Immutable data class or use ImmutableList from kotlinx.collections.immutable |
| Lambda recreated each recompose | Use remember { { } } to stabilize the lambda reference |
| Entire screen recomposes on small change | Split composable — each child reads only the state slice it needs |
| Derived value recalculated every recompose | Use derivedStateOf { } — recalculates only when source state actually changes |
| External library class is unstable | Wrap in @Stable class, or use Compose Compiler stability config file |
| Data class has var fields | Use only val in data classes passed to composables |
| Reading too much from ViewModel state | Hoist state down — pass only the specific fields each composable needs |
derivedStateOf — When and How
Creates a derived state that recalculates only when its source state changes. Use it when a computed value changes less frequently than the state it reads from.
// Wrong — recalculates on every recomposition
val isButtonEnabled = inputText.length > 3
// Correct — recalculates only when inputText changes
val isButtonEnabled by remember {
derivedStateOf { inputText.length > 3 }
}
// Classic example — scroll-to-top button
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}Interview tip: Use derivedStateOf when: (1) you compute a value from state, AND (2) the derived value changes less often than the source state. Don't use it for simple direct reads — the overhead costs more than it saves.
LazyColumn — Long List Best Practices
LazyColumn composes only the visible items plus a small buffer. Getting it wrong causes jank and dropped frames on any list with 50+ items.
| Do | Don't |
|---|---|
| LazyColumn with key = { it.id } | Column { items.forEach { } } for variable-length lists |
| Pass only item.id, item.title to child | Pass entire UiState object to each item |
| @Immutable data class for item model | Data class with var fields |
| remember { { } } for click handlers | onClick = { viewModel.onEvent(it) } inline in items |
| derivedStateOf for scroll-based visibility | Direct state read computed fresh each frame |
| contentType for heterogeneous lists | Single composable with when() branches for all types |
// Always provide a stable key
LazyColumn {
items(messages, key = { it.id }) { message ->
MessageRow(message = message)
}
}
// Use contentType for mixed-content lists
items(feed, key = { it.id }, contentType = { it.type }) { item ->
// ...
}Recommended Libraries
- Coil — Kotlin-first image loading for Compose. Coroutine-based, lightweight. Best choice for Jetpack Compose projects.
- Glide Compose — Glide's official Compose integration. Mature, feature-rich. Use if already using Glide in View-based code.
- kotlinx.collections.immutable — Immutable collections for Compose stability. Use ImmutableList instead of List to prevent recomposition.
- Accompanist — Google's Compose utilities. Permissions, system UI controller, placeholder. Some features graduated to official Compose.
Interview tip: Say: 'I'd verify with the Recomposition Highlighter in Layout Inspector before shipping, and set up a benchmark with Macrobenchmark to catch regressions in CI.'
State Hoisting Pattern
Move state up to the lowest common ancestor that needs it. Composables should be stateless where possible — easier to test and reuse.
- Stateless composable — receives values and callbacks as parameters; owns no state
- Stateful composable — holds state via remember or hoists from ViewModel
- Single source of truth — state lives in one place; composables read and emit, not own
@Composable
fun SearchBar(
query: String, // state passed in
onQueryChange: (String) -> Unit // event passed out
) { ... }Compose Performance Profiling — Perfetto & Macrobenchmark
Recomposition Highlighter shows what recomposes; Perfetto shows why it's slow; Macrobenchmark proves it in CI. Use all three — each answers a different question.
- Recomposition Highlighter (Layout Inspector) — highlights composables in red when they recompose; use this first to find hot spots
- Perfetto trace — captures composition, measure, layout, draw phases as Systrace slices; look for frames > 16ms
- FrameTimingMetric in Macrobenchmark — measures jank (frames > 16ms) in automated CI; catches regressions before release
- Baseline Profiles (AGP 8+) — pre-compile hot-path bytecode at install time; reduces cold start 20–40% on average, typically toward the lower end for most apps, first-frame latency up to 30%
- Composition tracing (Compose 1.3+) — emits composable names into Perfetto trace; requires profileable build type
// Macrobenchmark — measure scroll performance in CI
@LargeTest
@RunWith(AndroidJUnit4::class)
class FeedScrollBenchmark {
@get:Rule
val rule = MacrobenchmarkRule()
@Test
fun scrollFeed() = rule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(FrameTimingMetric()), // captures jank frames
iterations = 5,
startupMode = StartupMode.COLD,
setupBlock = { pressHome() }
) {
startActivityAndWait()
val feed = device.findObject(By.res("feed_list"))
feed.setGestureMargin(device.displayWidth / 5)
feed.fling(Direction.DOWN)
device.waitForIdle()
}
}
// Baseline Profile — pre-compile hot paths at install time
// (generated via BaselineProfileRule, shipped in src/main/baseline-prof.txt)
// Reduces cold start 20-40% on average on first run after installRecommended Libraries
- Macrobenchmark — Official Jetpack library for measuring app startup, scroll jank, and animation smoothness in CI. Integrates with FrameTimingMetric.
- Benchmark (Microbenchmark) — Jetpack library for measuring method-level performance. Use for algorithm benchmarks and serialization speed.
Interview tip: When discussing Compose performance, say: I'd use the Recomposition Highlighter to find hot spots, verify with a Perfetto trace, then add a Macrobenchmark with FrameTimingMetric to prevent CI regressions. Baseline Profiles ship with the build for 20–40% cold start improvement on average, typically toward the lower end for most apps.