← All topics
Android

Jetpack Compose

Recomposition, state & the snapshot system, side effects, modifiers, custom layout, performance, and the mental model behind declarative UI.

39 questions 6 junior25 mid8 senior

Compose is now table stakes for Android UI roles - Google’s recommended toolkit for new apps, and the area where interviewers most quickly tell apart people who copy patterns from people who understand the runtime.

A simple study path

Start with state, recomposition, remember, state hoisting, modifiers, and LazyColumn. Then learn effects, ViewModel collection, navigation, and testing. Compiler transforms, the slot table, custom layouts, and performance internals can wait until you are comfortable building normal screens.

What gets tested

  • Mental model - declarative UI (UI = f(state)), the three phases (composition → layout → drawing), the composable lifecycle.
  • State - mutableStateOf & the snapshot system, remember/rememberSaveable, state hoisting, stateless vs stateful, derivedStateOf.
  • Recomposition & performance - what triggers it, recomposition scopes & donut-hole skipping, stability (@Immutable/@Stable, unstable collections), deferred reads, Baseline Profiles.
  • Side effects - LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, produceState, snapshotFlow, rememberUpdatedState.
  • Layout & modifiers - modifier order, custom Layout, constraints/intrinsics, BoxWithConstraints, slot APIs.
  • Lists - LazyColumn, keys, contentType, pagination.
  • Ecosystem - navigation, theming, animation, gestures, interop (AndroidView/ComposeView), accessibility, insets, testing & previews.
  • Internals - the compiler transform, slot table, positional memoization (for senior roles).

How interviewers ask

Lots of “why does/doesn’t this recompose (or update)?” output questions (modifier order, stale remember, mutating a list in place), comparisons (remember vs rememberSaveable, collectAsState vs collectAsStateWithLifecycle), and “how would you optimize this screen?” The signal they want is that you understand recomposition and the phases - almost every Compose question reduces to those two ideas.

Prep tip: for any composable, be able to answer “what state does it read, which scope recomposes when that changes, and in which phase?” Master that and the performance questions answer themselves.

Start here

Core ideas you should be able to explain in plain language.

Core concepts

How do you use @Preview effectively, and what makes a composable preview-friendly?
Junior #compose#preview#tooling

@Preview renders a composable in Android Studio without running the app or a device - fast iteration on UI.

@Preview(showBackground = true)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Large font", fontScale = 1.5f)
@Composable
private fun ProfileCardPreview() {
    AppTheme {
        ProfileCard(user = User("Ada", "ada@x.com"))
    }
}

Useful features:

  • Multiple previews on one function (light/dark, font scales, locales, device sizes) - or a @PreviewParameter provider to render several data states (loading/empty/error/loaded) at once.
  • Custom annotation classes (@PreviewLightDark, or your own multi-preview annotation) to apply a standard set everywhere.
  • Interactive Preview and Live Edit for clicking through and editing without rebuilds.

What makes a composable preview-friendly (the real point):

  • It must be stateless / driven by parameters - a composable that fetches data from a ViewModel or reads a real repository can’t preview cleanly. Hoist state and pass plain data.
  • Wrap previews in your theme so colors/typography render correctly.
  • Pass fake/sample data rather than real dependencies.

This is a strong argument for the stateless composable + state hoisting pattern: previewability falls out of it for free. A screen split into a stateful “screen” (wires the ViewModel) and a stateless “content” (pure params) lets you preview the content in every state.

Bonus: previews double as screenshot tests (Paparazzi/Roborazzi) to catch visual regressions in CI without a device.

How does theming work in Compose?
Junior #compose#theming#material

MaterialTheme provides three systems down the tree via CompositionLocals: colors, typography, and shapes.

@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
    val colors = when {
        // Material 3 dynamic color (Android 12+): derive from the wallpaper
        dynamicColorAvailable() && darkTheme -> dynamicDarkColorScheme(LocalContext.current)
        dynamicColorAvailable() -> dynamicLightColorScheme(LocalContext.current)
        darkTheme -> DarkColors
        else -> LightColors
    }
    MaterialTheme(colorScheme = colors, typography = AppTypography, content = content)
}

Reading the theme anywhere inside:

Text("Hi", color = MaterialTheme.colorScheme.primary,
     style = MaterialTheme.typography.titleMedium)

Key points:

  • Single source of truth - define colors/type/shapes once; components read from MaterialTheme.*. Don’t hardcode colors in composables.
  • Dark mode is just a different ColorScheme. isSystemInDarkTheme() follows the system; toggling is swapping the scheme - the whole tree recomposes with new colors.
  • Dynamic color (Material You) derives a scheme from the user’s wallpaper on Android 12+. Provide static fallbacks for older versions.
  • Custom design systems - wrap or replace MaterialTheme with your own CompositionLocalProviders (custom spacing, brand colors) and expose them via a Theme object.
  • Theme values flow through staticCompositionLocalOf, so reads are cheap but changing the theme recomposes the provided subtree.
LazyColumn vs Column, and why provide keys to items?
Junior #compose#lazycolumn#performance#keys

Column composes and lays out all its children immediately, whether or not they’re on screen - fine for a handful of items, disastrous for a long/unbounded list. LazyColumn only composes the items currently visible (plus a small buffer) and recycles them as you scroll - the Compose equivalent of RecyclerView.

LazyColumn {
    items(users, key = { it.id }) { user ->
        UserRow(user)
    }
}

Why key = { it.id } matters:

  • By default, Lazy lists identify items by their position/index. If the list reorders, inserts, or removes items, Compose can mismatch state to the wrong item.
  • A stable key ties each item’s identity (and its remembered state, animations, scroll position) to the data, not the index. So when items move, Compose moves the existing composition instead of recomposing everything.
  • Without keys, deleting the first item makes every item below shift index, causing unnecessary recomposition and losing per-item state (e.g. an expanded row collapses, a half-typed field clears).

Other LazyColumn tools:

  • contentType - hint the type of each item so Compose reuses compositions of the same type more efficiently in heterogeneous lists.
  • rememberLazyListState() - observe/control scroll (with derivedStateOf for things like “show scroll-to-top”).
  • Don’t nest a vertically-scrolling LazyColumn inside a vertically-scrolling parent without a bounded height - it can crash or measure infinitely.
remember vs rememberSaveable - what's the difference?
Junior #compose#state

Both cache a value across recompositions, but they survive different events.

  • remember keeps a value across recompositions. It’s lost on configuration change (rotation) or process death, because the composition is recreated.
  • rememberSaveable also persists across configuration changes and process death by writing into the saved-instance-state Bundle.
// Survives recomposition only
var query by remember { mutableStateOf("") }

// Also survives rotation / process death
var query by rememberSaveable { mutableStateOf("") }

Use rememberSaveable for UI state the user would be annoyed to lose - text fields, scroll position, expanded/collapsed flags. Use remember for things that are cheap to recreate or derived from other state.

Gotcha: rememberSaveable can only store types that go in a Bundle (primitives, Parcelable, etc.). For a custom type, provide a Saver.

What is declarative UI, and how is Compose different from the View system?
Junior #compose#fundamentals#declarative

Declarative means you describe what the UI should look like for a given state, and the framework figures out how to update the screen. You don’t hold view references and mutate them; you re-describe the UI when state changes.

// Declarative (Compose): describe UI as a function of state
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) { Text("Count: $count") }
}

vs the imperative View system, where you fetch a widget and mutate it:

button.text = "Count: $count"   // you manually keep the view in sync

Key differences:

  • No XML / no findViewById - UI is Kotlin functions.
  • State-driven - when state changes, Compose recomposes (re-invokes the affected composables) instead of you manually updating views.
  • No view hierarchy inflation - composables don’t map 1:1 to View objects; Compose maintains its own tree and renders directly.
  • Single source of truth - the UI can’t drift out of sync with state because it’s derived from state.

A useful mental model: UI = f(state). Instead of imperatively poking widgets when data changes, you make the UI a pure function of state and let recomposition handle updates.

What is state hoisting, and what makes a composable stateless?
Junior #compose#state-hoisting#state

State hoisting is moving state up out of a composable to its caller, so the composable becomes stateless - it receives the value and a callback to change it, rather than owning the state.

The pattern is value down, events up:

// Stateless: owns no state, fully driven by parameters
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Stateful caller owns the state
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
    val query by viewModel.query.collectAsStateWithLifecycle()
    SearchBar(query = query, onQueryChange = viewModel::onQueryChange)
}

Why hoist:

  • Reusable - a stateless composable works anywhere; the caller decides where state lives.
  • Testable - you can render it with any value, no internal state to set up.
  • Single source of truth - state lives in one place (ViewModel or a parent), avoiding divergent copies.
  • Controllable - the parent can intercept, transform, or share the state.

Stateful vs stateless:

  • Stateful composables hold their own remember { mutableStateOf(...) } - convenient for self-contained widgets where the caller doesn’t care about the state.
  • Stateless ones take state as parameters - preferred for anything shared, tested, or driven by a ViewModel.

A common design is to offer both: a stateful overload (with a default rememberX state) that delegates to a stateless one - exactly how Compose’s own Scaffold/rememberScaffoldState are structured.

Use it in practice

Common implementation choices, debugging, and trade-offs.

Core concepts

Compare the side-effect APIs: LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope.
Mid #compose#side-effects

A side effect is anything that escapes the scope of a composable (network call, listener registration, logging). Composition can run often and unpredictably, so you must contain side effects in the right API:

LaunchedEffect(keys) - run a suspend block scoped to composition; cancelled on leave, restarted on key change. For coroutine work driven by the composition.

DisposableEffect(keys) - for effects that need cleanup. Register in the block, clean up in onDispose. Re-runs (dispose + re-setup) on key change.

DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, e -> /* ... */ }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}

SideEffect { } - runs after every successful recomposition. Use to publish Compose state to a non-Compose object (e.g. update an analytics property or a third-party controller).

rememberCoroutineScope() - returns a CoroutineScope tied to the composition that you launch from event callbacks (not during composition):

val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { snackbarHost.showSnackbar("Hi") } }) { ... }

How to choose:

  • Suspend work when entering / on key change → LaunchedEffect.
  • Need teardown (listeners, callbacks) → DisposableEffect.
  • Launch a coroutine in response to a user eventrememberCoroutineScope.
  • Sync Compose state to a non-Compose API every recomposition → SideEffect.

The rule: never call viewModel.load() or launch coroutines directly in a composable body - it’d fire on every recomposition. Use these APIs to scope effects correctly.

Explain LaunchedEffect. Why do its keys matter?
Mid #compose#side-effects#LaunchedEffect

LaunchedEffect runs a suspend block tied to the composition. It launches a coroutine when the composable enters the composition and cancels it when the composable leaves - perfect for one-off or ongoing async work driven by Compose.

LaunchedEffect(Unit) {
    viewModel.loadData()          // runs once when entering composition
}

The keys are the crucial part. LaunchedEffect(key1, key2, …) restarts the coroutine (cancels the old, starts a new) whenever any key changes. If keys don’t change across recompositions, the same coroutine keeps running.

LaunchedEffect(userId) {
    user = repository.loadUser(userId)   // reloads whenever userId changes
}
  • LaunchedEffect(Unit) or LaunchedEffect(true) - run once for the composable’s lifetime (key never changes). Good for “fire on first appearance.”
  • LaunchedEffect(key) - re-run when key changes. The classic bug is using Unit when you need it to re-run on a parameter change (stale data), or capturing a value that’s stale because it isn’t a key.

Output-based trap:

LaunchedEffect(Unit) {
    delay(2000)
    println(count)     // captures count at first composition - may be STALE
}

If count should be current, either add it as a key, or wrap it in rememberUpdatedState.

How do navigation, arguments, and ViewModels work in Compose?
Mid #compose#navigation

Navigation-Compose models the app as a NavHost with composable destinations addressed by routes, driven by a NavController.

val navController = rememberNavController()
NavHost(navController, startDestination = "feed") {
    composable("feed") {
        FeedScreen(onItemClick = { id -> navController.navigate("detail/$id") })
    }
    composable(
        "detail/{itemId}",
        arguments = listOf(navArgument("itemId") { type = NavType.StringType }),
    ) { backStackEntry ->
        val id = backStackEntry.arguments?.getString("itemId")!!
        DetailScreen(id)
    }
}

Key points:

  • Routes are strings (older API) or type-safe classes/objects with Kotlin Serialization (newer Navigation 2.8+ type-safe routes) - prefer type-safe to avoid stringly-typed bugs.
  • Arguments are declared with navArgument and read from the backStackEntry. Pass IDs, not whole objects - large/complex args don’t belong in the back stack.
  • ViewModel scoping - hiltViewModel() inside a composable {} scopes the ViewModel to that NavBackStackEntry, so it survives recomposition and config changes but is cleared when you pop the destination.
  • Nested graphs group related destinations; a ViewModel scoped to a nested graph (hiltViewModel(graphEntry)) can be shared across screens in a flow (e.g. a multi-step checkout).
  • Results between screens - use the previous entry’s SavedStateHandle, or share a graph-scoped ViewModel, rather than passing data forward and back through routes.

Common gotchas: don’t pass non-trivial objects as nav args (serialize an ID instead); use popUpTo/launchSingleTop to control the back stack; and remember each destination’s ViewModel lifecycle is tied to its back stack entry.

How do you diagnose and fix Compose performance problems?
Mid #compose#performance#recomposition

Approach it as measure → find the cause → fix, not guesswork.

Measure first:

  • Layout Inspector shows recomposition counts per composable - find what recomposes too often.
  • Compose compiler metrics report which composables are skippable/restartable and which parameters are unstable.
  • System Trace / Macrobenchmark for jank and frame timing.
  • Always profile a release build - debug Compose is much slower and misleading.

Common causes & fixes:

  1. Unstable parameters → composable can’t skip. Fix with ImmutableList, @Immutable/@Stable, or stable state classes.
  2. Reading state too high / too early → wide recomposition scope. Read state as low in the tree and as late in the phases as possible. Defer reads to lambda modifiers: Modifier.offset { }, graphicsLayer { }, drawBehind { } - these read during layout/draw, skipping composition.
  3. New lambda/object allocations each recomposition → break skipping. remember expensive objects; method references and stable lambdas help (strong skipping remembers lambdas).
  4. Rapidly-changing state read directly (scroll offset) → wrap with derivedStateOf so readers update only on meaningful changes.
  5. Work in composition → no heavy computation, sorting, or I/O in a composable body; remember(key) { } it or move it to the ViewModel.
  6. Missing keys in lazy lists → wasted recomposition on reorder; add key = { it.id }.

Ship-time wins:

  • Baseline Profiles - precompile hot paths (including Compose) so the first runs are AOT-compiled, cutting startup and scroll jank significantly.
  • Strong skipping mode (Kotlin 2.x) reduces manual stability work.
How do you do custom drawing in Compose? (Canvas, drawBehind, drawWithCache)
Mid #compose#canvas#drawing

Compose drawing happens in the draw phase via a DrawScope, which gives you drawLine, drawCircle, drawPath, drawRect, drawImage, etc. - all in pixels (.toPx() from dp).

Canvas composable - a dedicated drawing surface:

Canvas(Modifier.size(200.dp)) {
    drawCircle(color = Color.Red, radius = size.minDimension / 2)
    drawLine(Color.Black, start = Offset.Zero, end = Offset(size.width, size.height))
}

Modifier.drawBehind { } - draw behind a composable’s content (e.g. a custom background):

Text("Hi", Modifier.drawBehind { drawRoundRect(Color.Yellow, cornerRadius = CornerRadius(8f)) })

Modifier.drawWithContent { } - control ordering relative to content (drawContent() places the children’s drawing; draw before/after it). Great for overlays, scrims, masks.

Modifier.drawWithCache { } - cache expensive draw objects (paths, brushes, shaders) so they’re not recreated every draw frame:

Modifier.drawWithCache {
    val path = buildExpensivePath(size)   // computed only when size changes
    onDrawBehind { drawPath(path, Color.Blue) }
}

Performance notes:

  • Drawing is in the draw phase, so reading state inside a draw lambda (drawBehind { }) skips composition/layout - cheap for animations like progress bars.
  • Use drawWithCache for anything costly to build (Paths, gradients) so it’s rebuilt only when inputs change, not every frame.
  • For very heavy/continuous graphics, graphicsLayer (and RenderEffect) offload work to the GPU.
How do you handle gestures and touch input in Compose?
Mid #compose#gestures#input

Compose offers gesture handling at two levels.

High-level modifiers for common cases:

Modifier
    .clickable { onClick() }
    .combinedClickable(onClick = {}, onLongClick = {})
    .draggable(state = rememberDraggableState { delta -> offset += delta },
               orientation = Orientation.Horizontal)
    .scrollable(...)
    .swipeable(...) // or anchoredDraggable in newer APIs
    .transformable(...) // pinch/zoom/rotate

Low-level pointerInput for custom gestures - gives raw pointer events and coroutine-based detectors:

Modifier.pointerInput(Unit) {
    detectTapGestures(
        onTap = { pos -> /* ... */ },
        onDoubleTap = { /* ... */ },
        onLongPress = { /* ... */ },
    )
}

Modifier.pointerInput(Unit) {
    detectDragGestures { change, dragAmount ->
        change.consume()
        offset += dragAmount
    }
}

Key points:

  • pointerInput(key) restarts the gesture coroutine when the key changes - pass relevant state as the key (a common bug is pointerInput(Unit) capturing stale state).
  • Consume events (change.consume()) to stop them propagating to parents and avoid conflicting gestures.
  • Built-in detectors: detectTapGestures, detectDragGestures, detectTransformGestures, awaitPointerEventScope { awaitFirstDown() ... } for fully custom flows.
  • For accessibility, prefer the semantic modifiers (clickable adds roles/handlers) over raw pointerInput where possible, or add Modifier.semantics.
How do you handle window insets and edge-to-edge in Compose?
Mid #compose#insets#edge-to-edge

Insets are the system-reserved areas - status bar, navigation bar, IME (keyboard), display cutout. With edge-to-edge (default on Android 15+ when targeting SDK 35), your content draws behind the system bars, so you must apply insets so nothing important is hidden.

// Enable edge-to-edge in the Activity
enableEdgeToEdge()

// Apply insets as padding in Compose
Column(
    Modifier
        .fillMaxSize()
        .windowInsetsPadding(WindowInsets.systemBars)   // pad for status+nav bars
) { ... }

Common tools:

  • Modifier.windowInsetsPadding(WindowInsets.systemBars) / .statusBarsPadding() / .navigationBarsPadding() - pad content out of the system bars.
  • Modifier.imePadding() - pad for the keyboard so inputs aren’t covered; Modifier.safeDrawingPadding() covers all of the above.
  • Scaffold automatically supplies contentPadding accounting for its bars - apply it to the content.
  • WindowInsets.ime / navigationBarsPadding for fine control; .consumeWindowInsets() to avoid double-applying in nested layouts.

What to remember:

  • You typically want backgrounds to extend behind the bars (immersive look) but pad interactive/text content so it’s not under them - apply insets at the content level, not the root background.
  • Handle the IME with imePadding() (and adjustResize-style behavior is automatic in Compose).
  • Test with gesture nav, 3-button nav, and a display cutout.
How do you implement infinite scroll / pagination in Compose?
Mid #compose#paging#lazycolumn

Two approaches.

1. Paging 3 (recommended for real data):

// ViewModel
val items: Flow<PagingData<Item>> = Pager(PagingConfig(pageSize = 20)) {
    ItemPagingSource(api)
}.flow.cachedIn(viewModelScope)

// UI
val lazyItems = viewModel.items.collectAsLazyPagingItems()
LazyColumn {
    items(lazyItems.itemCount, key = lazyItems.itemKey { it.id }) { index ->
        lazyItems[index]?.let { ItemRow(it) }
    }
    // footer based on load state
    when (lazyItems.loadState.append) {
        is LoadState.Loading -> item { LoadingRow() }
        is LoadState.Error -> item { RetryRow { lazyItems.retry() } }
        else -> {}
    }
}

Paging 3 handles page requests, caching, retries, placeholders, and exposes loadState. collectAsLazyPagingItems() integrates it with LazyColumn. With a RemoteMediator + Room it becomes offline-first (DB is the source of truth).

2. Manual “load more” with scroll detection (simple lists):

val state = rememberLazyListState()
val shouldLoadMore by remember {
    derivedStateOf {
        val last = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
        last >= items.size - 5            // near the end
    }
}
LaunchedEffect(shouldLoadMore) {
    if (shouldLoadMore) viewModel.loadNextPage()
}

Note the derivedStateOf - visibleItemsInfo changes every scroll frame, but shouldLoadMore only flips occasionally, so this avoids recomposing on every frame.

When to use which: Paging 3 for production lists from network/DB (it solves caching, retry, dedup, placeholders). Manual approach for small or fully-in-memory lists where Paging is overkill.

How do you make a Compose screen accessible?
Mid #compose#accessibility#semantics

Compose builds a semantics tree parallel to the UI tree - it’s what TalkBack reads and what UI tests query. Much of it is automatic; you fill the gaps.

// Icons/images need a contentDescription (or null if purely decorative)
Icon(Icons.Default.Favorite, contentDescription = "Add to favorites")
Image(painter, contentDescription = null)   // decorative → skipped by TalkBack

// Add or override semantics
Modifier.semantics {
    contentDescription = "Profile photo of $name"
    role = Role.Button
    stateDescription = if (selected) "Selected" else "Not selected"
}

// Merge children into one announcement (e.g. a whole card read as one node)
Modifier.semantics(mergeDescendants = true) { }
Row(Modifier.clickable {}.semantics(mergeDescendants = true)) {
    Icon(...); Text("Settings")   // announced together, not separately
}

Key points:

  • contentDescription on Icon/Image is required for non-decorative graphics; pass null for decorative ones so they’re ignored.
  • mergeDescendants groups child semantics into a single focusable node - important so a card isn’t read as five separate items. clickable/toggleable merge automatically.
  • role, stateDescription, onClick label make custom controls understandable to assistive tech.
  • Touch targets should be ≥ 48dp (Modifier.minimumInteractiveComponentSize() / sizeIn).
  • testTag is also part of semantics (used by tests; excluded from accessibility by default).
  • Respect font scaling - use sp for text and avoid fixed heights that clip scaled text; honor dark mode and contrast.
How do you manage TextField state in Compose, and what is the recomposition concern?
Mid #compose#textfield#state

A TextField is stateless - it shows the value you give it and reports edits via onValueChange. You own the state (state hoisting):

var text by rememberSaveable { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })

rememberSaveable keeps the text across rotation/process death.

The recomposition concern: every keystroke updates the state, which recomposes the TextField (and anything reading text). For most screens that’s fine. Problems appear when:

  • onValueChange routes through a ViewModel + StateFlow round-trip - the extra hop can cause lag or cursor jumps if updates are async or debounced incorrectly. Keep the text state close to the field (often local rememberSaveable), and send only derived actions (search query) to the ViewModel via debounce.
  • Heavy work runs in onValueChange - keep it light; do validation/search reactively (e.g. snapshotFlow { text }.debounce(300)).

The modern API - TextFieldState (Compose Foundation BasicTextField with state-based API) manages text, selection, and composition more robustly than the value/callback pair, avoiding a class of cursor/state desync bugs:

val state = rememberTextFieldState()
BasicTextField(state = state)
// read with state.text

Key points:

  • Hoist text with rememberSaveable for config-change survival.
  • Don’t introduce async latency between keystroke and displayed value - it causes janky typing/cursor jumps.
  • Prefer the newer TextFieldState API for complex inputs; it handles selection/IME edge cases.
  • Use KeyboardOptions/KeyboardActions for input type and IME actions, and visualTransformation for masking (passwords, currency).
How do you mix Compose and the View system in both directions?
Mid #compose#interop#views

Interop goes both ways and is common during migration.

Views inside Compose - AndroidView:

AndroidView(
    factory = { context -> MapView(context) },  // create once
    update = { mapView -> mapView.setZoom(zoom) }, // re-run on state change
    modifier = Modifier.fillMaxSize(),
)
  • factory runs once to create the View.
  • update runs on initial composition and whenever a state it reads changes - bridge Compose state into the View here.
  • Use it for things Compose lacks or that are expensive to reimplement: MapView, WebView, AdView, custom legacy views, SurfaceView.
  • AndroidViewBinding wraps a whole XML layout via ViewBinding.

Compose inside Views - ComposeView:

// In XML or code, add a ComposeView, then:
composeView.setContent {
    MyComposable()
}
  • Lets you adopt Compose screen-by-screen or widget-by-widget inside a View-based app.
  • Set the right ViewCompositionStrategy (e.g. DisposeOnViewTreeLifecycleDestroyed) so the composition is disposed with the host’s lifecycle - especially in RecyclerView rows or Fragments.

Gotchas:

  • AndroidView’s update is where you push state; don’t recreate the View in factory on recomposition.
  • Watch lifecycle/disposal for ComposeView in lists and Fragments to avoid leaks.
  • Theming/CompositionLocal don’t automatically cross the boundary - pass values explicitly.
How does mutableStateOf update the Compose UI?
Mid #compose#state#snapshot

mutableStateOf holds a value that Compose can observe. When a composable reads that value, Compose remembers the dependency. Changing the value then schedules that part of the UI to run again with the new state.

var count by remember { mutableStateOf(0) }  // `by` uses property delegation
Text("$count")          // reading count subscribes this Text to changes
Button(onClick = { count++ }) { Text("+") }  // writing schedules recomposition

That is enough for most Junior and Mid interviews: state is read, the value changes, and the UI that read it recomposes.

Optional detail: the snapshot system. Compose stores observable state in snapshots so a group of reads sees a consistent view of state. This supports:

  • Controlled changes - Snapshot.withMutableSnapshot { } can apply a group of changes together. Ordinary UI state should still usually be updated from the main thread.
  • Consistency - within one recomposition pass you never see half-updated state.
  • Precise observation - snapshotFlow { } can turn any state read into a Flow.

Why remember is required: mutableStateOf(0) alone creates a new state object on every recomposition, resetting it. remember { } keeps the same state object across recompositions. rememberSaveable additionally survives recreation.

Storage options: mutableStateOf (single value), mutableStateListOf / mutableStateMapOf (observable collections that trigger recomposition on add/remove), and derivedStateOf (computed). Use the observable collections rather than a plain MutableList inside state, or mutations won’t trigger recomposition.

How should Compose collect state from a ViewModel?
Mid #compose#state#lifecycle#viewmodel

You convert the flow into Compose State so reads trigger recomposition:

@Composable
fun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    FeedContent(state)
}

collectAsState() vs collectAsStateWithLifecycle():

  • collectAsState() collects the flow as long as the composable is in composition - including when the app is in the background. It keeps the upstream active and doing work even when the screen isn’t visible.
  • collectAsStateWithLifecycle() (from lifecycle-runtime-compose) collects only while the lifecycle is at least STARTED, using repeatOnLifecycle under the hood. It stops collecting in the background and resumes in the foreground.

Why the lifecycle-aware one is the recommended default on Android:

  • Avoids wasted work, CPU, and battery while backgrounded.
  • Lets the upstream (stateIn(WhileSubscribed(5000))) actually stop, since collection is properly cancelled.
  • Prevents updates to UI state that isn’t visible.

Notes:

  • collectAsState() is still appropriate for non-Android / multiplatform Compose where there’s no lifecycle.
  • Pass an explicit lifecycle/minActiveState if you need a state other than STARTED.
  • It pairs naturally with the stateIn(..., WhileSubscribed(5000), ...) upstream pattern in the ViewModel.
What are the main animation APIs in Compose, and how do you pick one?
Mid #compose#animation

Compose animations are state-driven - you animate toward a target value and Compose interpolates.

High-level / value animations:

  • animate*AsState - animate a single value to a target. The simplest: animateColorAsState, animateDpAsState, animateFloatAsState.
    val size by animateDpAsState(if (expanded) 200.dp else 100.dp)
    Box(Modifier.size(size))
  • updateTransition - coordinate multiple values that change together based on one state, staying in sync.
  • AnimatedVisibility - animate a composable entering/leaving (enter/exit transitions: fade, slide, expand).
  • AnimatedContent - animate the swap between different content for different states.
  • Crossfade - fade between two layouts.
  • animateContentSize() - a modifier that animates size changes automatically.

Low-level / fine control:

  • Animatable - coroutine-driven, gives full control (e.g. fling, gesture-following, snapTo/animateTo), and is interruptible.
  • rememberInfiniteTransition - looping animations (pulsing, loading shimmer).

AnimationSpec customizes the how: tween(durationMillis, easing), spring(dampingRatio, stiffness), keyframes, repeatable.

How to choose:

  • One value to a target → animate*AsState.
  • Several values from one state → updateTransition.
  • Show/hide → AnimatedVisibility; swap content → AnimatedContent.
  • Gesture-following / manual control → Animatable.
  • Continuous loop → rememberInfiniteTransition.
What are the three phases of Jetpack Compose?
Mid #compose#phases#performance

Compose renders a frame in three phases, in order:

  1. Composition - what to show. Compose runs your @Composable functions to build/update the UI tree (the description of widgets). This is where recomposition happens.
  2. Layout - where to put it. Each node is measured and placed: the tree is measured top-down, then children are placed. This is the measure/place pass.
  3. Drawing - how it looks. Each node draws itself onto the canvas.
State change → Composition → Layout → Drawing → frame on screen

Why this matters for performance: a state change doesn’t always need all three phases. If you can defer a state read to a later phase, you skip the earlier ones:

// ❌ reads scroll offset in composition → recomposes every scroll frame
Box(Modifier.offset(x = scrollState.value.dp))

// ✅ reads it in the layout phase via a lambda → skips composition
Box(Modifier.offset { IntOffset(scrollState.value, 0) })

The lambda version of offset/graphicsLayer/drawBehind reads the value during layout/draw, not composition - so a changing offset re-runs only layout/draw, not your composable. This deferred-read technique is a core Compose performance pattern.

What does the key() composable do, and why is identity important in Compose?
Mid #compose#key#state#identity

Compose identifies each composable by its position in the source/call tree (positional memoization). That’s how it knows which remembered state belongs to which call. The key() composable lets you override that identity with a value you control.

@Composable
fun Names(names: List<Name>) {
    Column {
        for (name in names) {
            key(name.id) {                 // tie identity to id, not position
                NameRow(name)              // its remembered state follows the data
            }
        }
    }
}

Why it matters: without key, if the list reorders or you insert/remove an item, the position of each NameRow changes, so Compose mismatches remembered state, animations, and focus to the wrong items. Wrapping in key(name.id) ties identity to the data, so Compose moves existing state with the item instead of recreating it.

Where you see it:

  • Manual loops (for inside Column) - wrap each iteration in key().
  • LazyColumn/LazyRow - the items(list, key = { it.id }) parameter does the same thing for lazy lists.

Symptoms of missing keys:

  • A row’s expanded/collapsed state or half-typed text jumps to the wrong item after a reorder/delete.
  • Item animations glitch.
  • Unnecessary recomposition because state can’t be matched.
What is CompositionLocal? When should you use it (and when not)?
Mid #compose#compositionlocal#theming

CompositionLocal provides a value implicitly down the composition tree, so deeply nested composables can read it without passing it through every parameter. It’s how MaterialTheme, LocalContext, LocalDensity, and LocalContentColor work.

val LocalSpacing = compositionLocalOf { Spacing() }

CompositionLocalProvider(LocalSpacing provides Spacing(large = 24.dp)) {
    MyScreen()   // anything inside can read LocalSpacing.current
}

@Composable
fun MyScreen() {
    val spacing = LocalSpacing.current
    Column(Modifier.padding(spacing.large)) { ... }
}

Two flavors:

  • compositionLocalOf - changing the value recomposes only composables that read it (tracked). Use for values that change.
  • staticCompositionLocalOf - not tracked; changing it recomposes the entire provided subtree. Use for values that essentially never change (more efficient reads). Theme colors that rarely change often use this.

When to use it: truly cross-cutting, ambient data many layers deep - theming, density, locale, a logged-in user’s display prefs.

When not to use it: it makes data flow implicit, which hurts readability and testability. Don’t use it for:

  • Data only one or two levels down - just pass a parameter.
  • ViewModel/business state - pass it explicitly; CompositionLocal hides dependencies and makes composables harder to preview/test.

Rule of thumb: “CompositionLocal for ambient, rarely-changing, widely-needed values (theme, density). Explicit parameters for everything else.”

What is derivedStateOf, and when should you use it instead of a plain calculation?
Mid #compose#derivedStateOf#performance#state

derivedStateOf creates a state object whose value is computed from other state, but only notifies readers when the computed result actually changes - not every time an input changes.

Use it when a frequently-changing state feeds a rarely-changing derived value:

val listState = rememberLazyListState()

// Recomputes as you scroll, but only emits true/false transitions
val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

if (showButton) ScrollToTopButton()

Here firstVisibleItemIndex changes on every scroll frame, but showButton only flips false→true→false. Without derivedStateOf, any composable reading showButton would recompose on every scroll tick. With it, recomposition happens only when the boolean changes - a big saving.

When NOT to use it: when the output changes about as often as the input. derivedStateOf has overhead, so for val full = "$first $last" (changes whenever inputs do) a plain calculation is better - wrapping it adds cost for no benefit.

The decision rule: reach for derivedStateOf when one or more rapidly-changing states collapse into a value that changes far less often. If input-change-rate ≈ output-change-rate, just compute it directly.

Common pairing: remember { derivedStateOf { } } - the remember keeps the derived-state object across recompositions; the derivedStateOf controls when readers are notified.

What is the lifecycle of a composable?
Mid #compose#lifecycle#composition

A composable’s lifecycle is much simpler than an Activity’s - it has three events:

  1. Enters the composition - the composable is called for the first time and added to the composition tree.
  2. Recomposes - re-executed zero or more times as the state it reads changes. This can happen frequently and in any order.
  3. Leaves the composition - removed from the tree (e.g. an if becomes false, a list item scrolls off, the screen is gone).
Enter composition → Recompose* (0..n) → Leave composition

What this means in practice:

  • remember survives across recompositions but is lost when the composable leaves the composition (and re-created if it re-enters). rememberSaveable additionally survives Activity recreation.
  • Effects are scoped to this lifecycle: LaunchedEffect’s coroutine is cancelled when the composable leaves; DisposableEffect.onDispose runs on leave (or key change).
  • Recomposition is not sequential or guaranteed - composables can recompose in parallel, be skipped, or run out of order, so they must be side-effect free in their body. Never rely on execution order or mutate external state directly in composition.
  • A composable can leave and re-enter (scrolling a LazyColumn item off and back) - its remembered state resets unless hoisted or rememberSaveable/keyed.

Contrast with Views: there’s no onCreate/onDestroy per widget; “creation” is entering composition and “destruction” is leaving it. Identity is by call-site position (or key).

What is the slot API pattern (content lambdas) in Compose?
Mid #compose#slot-api#reusability

The slot API is the pattern of accepting @Composable lambdas as parameters, letting callers inject their own content into named “slots.” It’s how Compose builds flexible, reusable components without exploding into dozens of configuration parameters.

@Composable
fun Card(
    title: @Composable () -> Unit,
    actions: @Composable RowScope.() -> Unit = {},
    content: @Composable () -> Unit,
) {
    Column(Modifier.padding(16.dp)) {
        title()
        Spacer(Modifier.height(8.dp))
        content()
        Row { actions() }
    }
}

// Caller fills the slots with whatever it wants
Card(
    title = { Text("Profile", style = MaterialTheme.typography.titleLarge) },
    actions = { TextButton(onClick = {}) { Text("Edit") } },
) {
    ProfileBody()
}

You see this everywhere in Material: Scaffold(topBar = {}, bottomBar = {}, floatingActionButton = {}) { content }, Button(content = { }), TopAppBar(title, navigationIcon, actions).

Why it’s powerful:

  • Inversion of control - the component owns layout/behavior; the caller owns what goes inside. No boolean/enum config soup.
  • Reusable & composable - one Card serves countless use cases.
  • RowScope/ColumnScope receivers on a slot give the caller scoped modifiers (Modifier.weight, align) inside that slot.
  • Trailing-lambda ergonomics - the last content slot reads cleanly with { }.
What triggers recomposition in Jetpack Compose, and how do you avoid doing it too often?
Mid #compose#performance#state

Recomposition is Compose re-running a composable to update the UI. It’s triggered when a State object that the composable reads changes value. Compose tracks reads at runtime, so only composables that actually read the changed state recompose - not the whole tree.

Keeping it cheap comes down to a few habits:

  • Read state as late as possible. Pass lambdas or state down rather than values, so only the leaf that needs the value recomposes. Defer reads into Modifier.drawBehind/graphicsLayer lambdas for things like scroll offsets.
  • Hoist state so a frequently-changing value lives close to where it’s used, not at the top of the screen.
  • Use stable types. Compose skips a composable if its inputs are stable and unchanged. List is unstable; prefer ImmutableList (kotlinx.collections.immutable) or mark classes @Immutable/@Stable.
  • Provide keys in LazyColumn items so Compose can match items across changes instead of recomposing everything.
  • Don’t allocate in composition. Wrap expensive computations in remember(key) so they don’t rerun every recomposition.
// Bad: passes a value, recomposes on every count change
Header(count = count)

// Better: pass a lambda, the read happens inside Header only when needed
Header(count = { count })

How to prove it in an interview: mention the Layout Inspector’s recomposition counts and the Compose compiler metrics/strong-skipping mode - they show you measure rather than guess.

When would you choose Compose or the View system?
Mid #compose#views#tradeoffs

Compose advantages:

  • Less code, one language - UI in Kotlin, no XML, no findViewById/ViewBinding boilerplate.
  • State-driven - UI = f(state) eliminates manual view-syncing bugs and inconsistent UI.
  • Powerful, simpler customization - custom layouts, animations, and theming are far easier than custom Views/onDraw.
  • Reusable via slot APIs; great tooling (Previews, live edit).

Compose costs / caveats:

  • Maturity gaps - some specialized widgets and third-party SDKs still ship Views (maps, ads, some media) - handled via AndroidView interop.
  • Performance footguns - easy to cause excessive recomposition if you don’t understand stability/phases; needs Baseline Profiles to match View startup.
  • Learning curve - recomposition, state, and effects are a different mental model.
  • Min SDK / size - adds runtime; fine for most apps but a consideration for tiny ones.

When you might stick with Views:

  • A large existing View codebase - migrate incrementally (Compose in a ComposeView) rather than rewrite.
  • Heavy reliance on a View-only SDK with no Compose equivalent.
  • Team without Compose experience on a tight timeline.

The honest interview answer: Compose is Google’s recommended default for new UI in 2024+, and most shops are adopting it. But it’s not all-or-nothing - interop lets Compose and Views coexist, so the real-world answer is usually “Compose for new screens, interop for the rest,” not a big-bang rewrite.

Why can remember return stale data when an input changes?
Mid #compose#output-based#remember#keys
@Composable
fun UserCard(userId: String) {
    // Caches the FIRST userId's data forever
    val userData = remember { loadUserSummary(userId) }
    Text(userData.name)
}

The bug: remember { } with no key computes its value once and reuses it for the composable’s whole lifetime. If the parent re-renders UserCard with a different userId (same position in the tree), remember does not recompute - it keeps the original user’s data. The card shows the wrong user.

The fix - key the remember on the inputs it depends on:

val userData = remember(userId) { loadUserSummary(userId) }

Now when userId changes, remember discards the cached value and recomputes.

remember(key1, key2) recomputes whenever any key changes - exactly like LaunchedEffect’s keys. A keyless remember { } means “compute once, never again for this slot.”

Related gotchas:

  • Don’t do real I/O in remember/composition (loadUserSummary blocking is bad regardless) - use LaunchedEffect/produceState. The example simplifies to show the keying bug.
  • The same stale-closure issue hits LaunchedEffect(Unit) { ...uses userId... } - add userId as a key or use rememberUpdatedState.
Why does changing a MutableList sometimes fail to update Compose?
Mid #compose#output-based#state#collections
@Composable
fun BrokenList() {
    val items = remember { mutableStateOf(mutableListOf("a")) }
    Column {
        Button(onClick = { items.value.add("b") }) { Text("Add") }   // ❌ no update
        items.value.forEach { Text(it) }
    }
}

The bug: tapping “Add” mutates the list in place. The MutableState still holds the same list reference, so .value hasn’t “changed” by Compose’s equality check - no recomposition is scheduled, and the new item never appears.

Two correct approaches:

1. Use an observable collection - mutableStateListOf:

val items = remember { mutableStateListOf("a") }
Button(onClick = { items.add("b") }) { Text("Add") }   // ✅ add triggers recomposition
items.forEach { Text(it) }

mutableStateListOf (and mutableStateMapOf) are snapshot-aware: structural changes (add/remove/set) notify Compose.

2. Use an immutable list and replace the reference:

var items by remember { mutableStateOf(listOf("a")) }
Button(onClick = { items = items + "b" }) { Text("Add") }  // ✅ new list → new reference

Assigning a new list changes .value, so Compose detects it.

Why this happens: MutableState schedules recomposition when .value is set to a different value (by equals). Mutating the contained list doesn’t change the reference, so nothing fires. Either make the container observable (mutableStateListOf) or always assign a new immutable instance.

Bonus: the immutable approach also keeps the parameter stable for child composables - better for skipping recomposition.

Why does Modifier order change a composable's appearance?
Mid #compose#output-based#modifier
// A
Box(Modifier.padding(16.dp).background(Color.Red).size(100.dp))

// B
Box(Modifier.background(Color.Red).padding(16.dp).size(100.dp))

Result:

  • A - 16dp of transparent space, then a red 100dp box. The padding is applied before the background, so the background only covers the area inside the padding.
  • B - a red box with 16dp of red padding inside it before the content. The background is applied first, so it fills the outer bounds, and padding insets the content within the red area.

Why: Modifiers are applied in order, left to right - each one wraps the result of the previous. The order literally is the order of operations:

  • A: “add padding, then draw background inside that” → background sits inside the padded region.
  • B: “draw background, then pad” → background covers everything, padding pushes content in.

The general rule: Compose modifiers are not commutative. padding().background()background().padding(). Also:

  • size() then padding() vs padding() then size() changes whether padding is inside or outside the declared size.
  • clip() before background() clips the background; after, it doesn’t.
  • clickable() before padding() makes the padded area clickable; after, only the inner area is.

Interview takeaway: “Modifiers compose like nested wrappers in declaration order - position padding, background, clip, and clickable deliberately.”

Optional deep dives

Internals and broader design questions to study after the core material.

Core concepts

How do you build a custom layout in Compose? Explain the measure/place model.
Senior #compose#layout#custom-layout

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Use the Layout composable (or a Modifier.layout). Compose’s layout protocol has one rule: measure children once, then place them. Constraints flow down, sizes flow up.

@Composable
fun SimpleColumn(content: @Composable () -> Unit, modifier: Modifier = Modifier) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        // 1. Measure each child with constraints
        val placeables = measurables.map { it.measure(constraints) }

        // 2. Decide our own size
        val width = placeables.maxOf { it.width }
        val height = placeables.sumOf { it.height }

        // 3. Place children
        layout(width, height) {
            var y = 0
            placeables.forEach { p ->
                p.placeRelative(x = 0, y = y)
                y += p.height
            }
        }
    }
}

The three steps:

  1. Measure - call measurable.measure(constraints) on each child exactly once (measuring twice throws). You may tighten/loosen the constraints you pass down.
  2. Size yourself - call layout(width, height) based on children’s measured sizes.
  3. Place - inside the layout {} block, position each Placeable with placeRelative/place.

Key concepts interviewers probe:

  • Constraints = min/max width and height passed top-down. A child must size within them.
  • Single-pass measurement - Compose layout is single-pass for performance (no double measure like some View layouts), which is why measuring a child twice is an error.
  • SubcomposeLayout - for the rare case where you must measure something before composing its children (e.g. BoxWithConstraints, lazy lists). It’s more expensive; avoid unless needed.
  • Intrinsic measurements (Modifier.height(IntrinsicSize.Min)) let a parent query a child’s natural size when one-pass isn’t enough.
How do you write a custom Modifier, and why prefer Modifier.Node over composed { }?
Senior #compose#modifier#performance

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A simple custom modifier is just a chaining extension that combines existing modifiers:

fun Modifier.card() = this
    .clip(RoundedCornerShape(12.dp))
    .background(MaterialTheme.colorScheme.surface)
    .padding(16.dp)

For modifiers that need state or to participate in layout/draw, there are two approaches:

Modifier.composed { } (legacy) - lets you call composable functions (like remember) inside a modifier. The problem: it’s a factory that recomposes, doesn’t get inlined/optimized well, allocates per use, and can hurt performance.

Modifier.Node (modern, recommended) - a lower-level API where you implement a Modifier.Node and a ModifierNodeElement. It’s more efficient: nodes are long-lived, not recreated on recomposition, can directly implement DrawModifierNode, LayoutModifierNode, PointerInputModifierNode, etc., and avoid the composition overhead of composed.

fun Modifier.circleBorder(color: Color) = this then CircleBorderElement(color)

private data class CircleBorderElement(val color: Color) :
    ModifierNodeElement<CircleBorderNode>() {
    override fun create() = CircleBorderNode(color)
    override fun update(node: CircleBorderNode) { node.color = color }
}

private class CircleBorderNode(var color: Color) : DrawModifierNode, Modifier.Node() {
    override fun ContentDrawScope.draw() {
        drawContent()
        drawCircle(color, style = Stroke(2.dp.toPx()))
    }
}

Why this matters: Google migrated all built-in modifiers off composed to Modifier.Node for performance. Knowing to prefer Modifier.Node (and that composed { } is discouraged for stateful/drawing modifiers) signals you understand Compose performance at a deeper level.

Rule of thumb: plain chaining for stateless combos; Modifier.Node for anything stateful, drawing, or layout-affecting; avoid composed { } in new code.

How does the Compose compiler work? What is positional memoization and the slot table?
Senior #compose#internals#compiler

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

@Composable is not a normal function - the Compose compiler plugin transforms it. The key transformations:

  • $composer parameter - every composable gets a hidden Composer parameter threaded through. The Composer is how composition reads/writes the tree.
  • Group calls - the compiler inserts startRestartGroup/endRestartGroup (and movable/replaceable groups) so Compose can track and restart just this scope.
  • Skipping logic - it generates code to compare parameters and skip the body if they’re stable and unchanged.

The slot table is the in-memory data structure that stores composition state - the tree of groups, remembered values, and CompositionLocals. It’s a flat, gap-buffer-backed array optimized for the common case: re-running composables in the same order.

Positional memoization is the core idea: Compose identifies each composable and each remember by its position in the execution order (call site), not by a name. That’s why:

  • remember { } at the same call site returns the same stored value across recompositions.
  • Calling composables conditionally (if) is fine, but reordering them without key() can confuse identity - hence key() to give stable identity in loops.
// The compiler turns this:
@Composable fun Greeting(name: String) { Text("Hi $name") }
// into roughly:
fun Greeting(name: String, $composer: Composer, $changed: Int) {
    $composer.startRestartGroup(...)
    if ($changed and 0b1 == 0 && $composer.skipping) { $composer.skipToGroupEnd() }
    else { Text("Hi $name", $composer) }
    $composer.endRestartGroup()?.updateScope { Greeting(name, it, $changed) }
}

Why this matters: it explains why the rules exist - why composables must be side-effect-free and idempotent (they re-run), why identity is positional (slot table), and why stability enables skipping.

What are constraints, intrinsic measurements, and BoxWithConstraints?
Senior #compose#layout#constraints

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Constraints are the min/max width and height a parent passes to a child during the layout phase. A child must choose a size within them. They flow top-down; measured sizes flow bottom-up.

Layout(content) { measurables, constraints ->
    // constraints.maxWidth, minHeight, etc.
}

The single-pass rule means a parent normally can’t know a child’s size before measuring it. Two escape hatches:

Intrinsic measurements - query a child’s “natural” size without a full measure pass. Modifier.height(IntrinsicSize.Min) makes a Row tall enough for its tallest child, etc. Used when one child’s size should depend on a sibling’s natural size (e.g. a divider matching text height). It costs an extra measurement, so use sparingly.

Row(Modifier.height(IntrinsicSize.Min)) {
    Text("Left")
    Divider(Modifier.fillMaxHeight().width(1.dp))   // matches the Row's content height
    Text("Right")
}

BoxWithConstraints - exposes the incoming constraints inside the composable so you can compose different content based on available space:

BoxWithConstraints {
    if (maxWidth < 600.dp) PhoneLayout() else TabletLayout()
}

It’s built on SubcomposeLayout (it composes children after knowing constraints), which is more expensive than a normal layout - don’t reach for it when a regular Modifier/weight approach works. Prefer it only for genuine “I must know the size before deciding what to compose” cases (responsive/adaptive layouts).

What do produceState and snapshotFlow do?
Senior #compose#side-effects#produceState#snapshotFlow

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Both bridge between Compose state and coroutines/flows, in opposite directions.

produceState - turn a coroutine/async source into Compose State. It launches a coroutine (like LaunchedEffect) and gives you a value you set over time.

@Composable
fun userState(userId: String): State<Result<User>> = produceState(
    initialValue = Result.Loading,
    key1 = userId,
) {
    value = try { Result.Success(repo.load(userId)) }
            catch (e: Exception) { Result.Error(e) }
    // optional awaitDispose { } for cleanup
}

It’s essentially remember { mutableStateOf(initial) } + LaunchedEffect combined - ideal for “load this async and expose it as state.”

snapshotFlow - the reverse: turn Compose State reads into a Flow. It observes the state read inside its block and emits when that state changes.

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .distinctUntilChanged()
        .filter { it > 10 }
        .collect { analytics.log("scrolled deep") }
}

Use it to apply Flow operators (debounce, filter, map) to Compose state, or to react to scroll/gesture state with coroutine logic.

How to choose:

  • Async data → Compose state to render: produceState.
  • Compose state → a Flow to process with operators or side effects: snapshotFlow.

Both are lifecycle-scoped to the composition and cancel when it leaves.

What is 'stability' in Compose, and why can an unstable parameter hurt performance?
Senior #compose#stability#performance#recomposition

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Compose skips recomposing a composable if all its parameters are stable and unchanged since last time. A type is stable if Compose can trust that:

  1. equals() is consistent, and
  2. if a public property changes, Compose is notified.

If a parameter is unstable, Compose can’t prove it’s unchanged, so it can’t skip - the composable recomposes even when nothing meaningfully changed.

What’s stable: primitives, String, function types, @Immutable/@Stable-annotated types, and data classes whose properties are all stable.

What’s unstable (common culprits):

  • List, Map, Set - the interface could be backed by a mutable implementation, so Compose treats them as unstable.
  • Classes from other modules the compiler can’t analyze (unless annotated).
  • Classes with var properties (mutable, no change notification).
// items: List<Item> is unstable → this recomposes even when items are equal
@Composable fun Feed(items: List<Item>) { ... }

// Fix 1: use a stable collection
@Composable fun Feed(items: ImmutableList<Item>) { ... }

// Fix 2: annotate the type
@Immutable data class FeedData(val items: List<Item>)

Fixes:

  • Use kotlinx.collections.immutable (ImmutableList/persistentListOf) for list params.
  • Annotate model classes with @Immutable / @Stable when you guarantee the contract.
  • Kotlin 2.x strong skipping mode relaxes this - it can skip composables with unstable params if instances are referentially equal, and remembers unstable lambdas - reducing how often you need manual fixes. Still, modeling stable state is good practice.

How to diagnose: the Compose compiler metrics report tells you which composables are skippable and which parameters are unstable.

What is a recomposition scope, and what is the 'donut-hole skipping' optimization?
Senior #compose#recomposition#performance

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A recomposition scope is the smallest restartable unit Compose can re-execute - roughly, a @Composable function (and certain inline blocks). When state read inside a scope changes, Compose re-runs only that scope, not the whole tree. This is smart/partial recomposition.

Donut-hole skipping: if a composable reads state, only the part that reads it recomposes - a child that doesn’t read it can be skipped even though its parent recomposed. The state read is the “hole”; the surrounding dough is skipped.

@Composable
fun Screen() {
    var count by remember { mutableStateOf(0) }
    Column {
        ExpensiveHeader()              // does NOT read count → skipped on count change
        Text("Count: $count")         // reads count → recomposes
        Button(onClick = { count++ }) { Text("+") }
    }
}

When count changes, only the Text recomposes; ExpensiveHeader is skipped (its inputs didn’t change and it’s skippable).

Practical implications:

  • Read state as low as possible. Reading count in Screen’s body would expand the scope; reading it inside Text keeps the hole small.
  • Defer reads to lambdas/later phases (Modifier.offset { }) to avoid composition entirely.
  • A composable is only skippable if its parameters are stable (see stability) - unstable params force it to recompose even when the parent does.
  • Returning Unit and not reading changed state are what let Compose skip a scope.
What problem does rememberUpdatedState solve?
Senior #compose#rememberUpdatedState#side-effects

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

It solves the stale-capture problem when a long-lived effect needs to always see the latest value of a parameter, but you don’t want the effect to restart when that value changes.

The classic case - a one-shot effect with a callback that might change:

@Composable
fun AutoDismiss(onTimeout: () -> Unit) {
    // Keep the latest onTimeout without restarting the timer
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {        // runs ONCE - keyed on Unit on purpose
        delay(5000)
        currentOnTimeout()        // calls the LATEST callback, not the first
    }
}

The dilemma without it:

  • If you put onTimeout as a LaunchedEffect key, the 5-second timer restarts every time the parent passes a new lambda - the dismiss never fires.
  • If you key on Unit and call onTimeout directly, the effect captures the first lambda - stale; later updates are ignored.

rememberUpdatedState gives you a stable holder whose .value is updated on every recomposition to the newest value, while the effect itself stays keyed on Unit (never restarts). Best of both: timer runs once, callback is always current.

When to use it: long-running effects (timers, animations, listeners started once) that reference frequently-changing parameters/callbacks you want fresh but not as restart triggers.