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.

Open Interactive Chapter →

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
StabilityWhat Compose doesExamples
StableCan skip recomposition if all parameters are equal (via equals()). Safe to skip.Int, String, Boolean, @Stable class, @Immutable data class with val fields only
UnstableCannot 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.

ProblemFix
Passing List as parameterWrap in @Immutable data class or use ImmutableList from kotlinx.collections.immutable
Lambda recreated each recomposeUse remember { { } } to stabilize the lambda reference
Entire screen recomposes on small changeSplit composable — each child reads only the state slice it needs
Derived value recalculated every recomposeUse derivedStateOf { } — recalculates only when source state actually changes
External library class is unstableWrap in @Stable class, or use Compose Compiler stability config file
Data class has var fieldsUse only val in data classes passed to composables
Reading too much from ViewModel stateHoist 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.

DoDon't
LazyColumn with key = { it.id }Column { items.forEach { } } for variable-length lists
Pass only item.id, item.title to childPass entire UiState object to each item
@Immutable data class for item modelData class with var fields
remember { { } } for click handlersonClick = { viewModel.onEvent(it) } inline in items
derivedStateOf for scroll-based visibilityDirect state read computed fresh each frame
contentType for heterogeneous listsSingle 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 install

Recommended 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.
SeniorUses Recomposition Highlighter to spot excessive recompositions
StaffRecords Perfetto traces, interprets composition/draw phases, writes Macrobenchmark to catch regressions
PrincipalOwns the performance budget: defines frame time SLA, integrates Macrobenchmark in release pipeline, ships Baseline Profiles

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.

Test your knowledge

This chapter includes 7 quiz questions covering all core concepts. Open the interactive experience to test yourself.

Start Quiz →