← All topics
Android

Jetpack Compose

Build a solid Compose mental model, then use it to reason about state, recomposition, effects, layouts, lists, and performance.

39 questions ★ 17 high priority 6 junior25 mid8 senior
Jetpack Compose progress 0 of 39 completed (0%)

Compose interviews get much easier once you stop treating every API as a fact to memorize. Most questions come back to the same few ideas: UI is produced from state, Compose tracks where that state is read, and it reruns only the work that may now be out of date.

If you are newer to Compose

Start with the starred questions in Start here. Be able to build a small screen with hoisted state, explain remember and rememberSaveable, and render a list with stable keys. Then move to recomposition, effects, ViewModel state, navigation, and testing. That is enough to handle a large share of junior and mid-level interviews.

If you already ship Compose

Use the same questions as a quick check, but push each answer one step further. Talk about ownership, lifecycle, failure cases, and what you would measure before optimizing. The senior material covers stability, phase-aware state reads, custom layout, modifier nodes, and runtime internals. Those details are useful, but only when they help explain a real engineering decision.

A reliable way to answer

For any Compose snippet or design question, walk through these four checks:

  1. State: What can change, and who owns it?
  2. Read: Which composable or phase reads that state?
  3. Identity: What keeps state attached to the right item or call site?
  4. Lifetime: When should the work start, restart, stop, or be restored?

For performance questions, add a fifth check: what evidence shows there is a problem? Mention a release build, a system trace, a benchmark, or compiler reports before proposing fixes.

What interviewers usually probe

  • State hoisting, remember, rememberSaveable, and observable collections
  • Recomposition, the composition, layout, and drawing phases, and deferred reads
  • LaunchedEffect, effect keys, cleanup, and stale callbacks
  • Modifier order, lazy-list keys, ViewModel state collection, and navigation
  • Stability, skipping, state ownership, and performance diagnosis for senior roles

The stars mark the questions worth covering on a first pass. The unstarred ones are still useful, but they are better treated as follow-ups than as a checklist you must memorize.

Useful references

Frequently asked. Prioritize these in your first pass.

Start here

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

Mental model

What does declarative UI mean, and how is Compose different from Views?
Junior #compose#fundamentals#declarative

In a declarative UI, you describe what the screen should look like for the current state. When the state changes, Compose runs the relevant composables again and updates the UI.

@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) {
        Text("Count: $count")
    }
}

Counter does not find a text view and change its text. It simply says, “for this value of count, show this button.” A useful shorthand is:

UI = f(state)

The View system is usually imperative. Code keeps references to widgets and updates them directly:

countText.text = "Count: $count"
incrementButton.isEnabled = count < 10

The practical differences are:

  • Compose UI is written in Kotlin rather than inflated from XML.
  • State is the source of truth. The UI is derived from it.
  • A composable call does not correspond one-to-one with an Android View.
  • Recomposition can rerun a function, skip it, or discard the result, so the composable body should be free of uncontrolled side effects.

An interview answer should not stop at “Compose has no XML.” The important change is ownership: application state drives the UI, instead of UI objects quietly becoming another place where state lives.

Senior follow-up: declarative does not mean Compose rebuilds the whole screen for every change. The runtime tracks state reads and can recompose a small scope, then skip layout or drawing work that is still valid.

State fundamentals

What is the difference between remember and rememberSaveable?
Junior #compose#state#remember

Both keep a value across recompositions. The difference is what happens when the composition itself is recreated.

var menuOpen by remember { mutableStateOf(false) }
var query by rememberSaveable { mutableStateOf("") }
  • remember stores the value in the current composition. It is forgotten when that call leaves the composition, including after a configuration change that recreates the Activity.
  • rememberSaveable also saves a compatible value through Android’s saved-state mechanism. It can restore after configuration change and system-initiated process recreation.

Use remember for objects that are cheap to recreate or only meaningful while the composable is present. Use rememberSaveable for small pieces of UI state a user would notice losing, such as entered text, a selected tab, or an expanded section.

There are two limits worth saying out loud:

  1. rememberSaveable is not permanent storage. It does not replace Room or DataStore, and it will not restore state after the user deliberately removes the task.
  2. The value must be saveable. Common primitives and Parcelable values work; custom types may need a Saver.

Also check the owner. Screen data and business state usually belong in a ViewModel, with essential restoration inputs kept in SavedStateHandle. rememberSaveable is mainly for UI element state.

What is state hoisting, and where should Compose state live?
Junior #compose#state-hoisting#state#udf

State hoisting means moving state to the caller and giving the child its current value plus events it can raise. People often summarize it as state down, events up.

@Composable
fun SearchField(
    query: String,
    onQueryChange: (String) -> Unit,
) {
    TextField(value = query, onValueChange = onQueryChange)
}

SearchField is stateless. That makes it easy to preview, test, and reuse. The caller can keep query locally, in a plain state holder, or in a ViewModel.

A practical placement rule is to hoist state:

  • to the lowest common parent of everything that reads it
  • high enough to include everything that can change it
  • together with other state that must change from the same events

Do not hoist by reflex. A tooltip’s open flag or a self-contained animation can stay local when no caller needs to control it. Screen state shaped by business logic usually belongs in a screen-level state holder such as a ViewModel.

@Composable
fun SearchRoute(viewModel: SearchViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    SearchScreen(
        state = state,
        onQueryChange = viewModel::onQueryChange,
    )
}

The strongest interview answer explains ownership, not just the value/onValueChange signature. Ask who needs to read the state, who changes it, and how long it must survive.

Lists

When should you use LazyColumn, and why do item keys matter?
Junior #compose#lazycolumn#performance#keys

Use a Column when the content is small and you want every child composed at once. Use a LazyColumn for a large or unknown number of items. A lazy layout only composes and lays out what its viewport needs.

LazyColumn {
    items(
        items = messages,
        key = { message -> message.id },
        contentType = { message -> message.kind },
    ) { message ->
        MessageRow(message)
    }
}

Without an explicit key, item identity is based on position. Insert an item at the top and all later positions move. Remembered state can then be associated with the wrong position, and Compose has less information for reusing work and animating moves.

A good key is unique, stable, and saveable in a Bundle if the item uses rememberSaveable. A database ID is ideal. A list index is usually not, because it changes when items are inserted, removed, or reordered.

contentType is a separate optimization for mixed lists. It tells the lazy layout which items have compatible structures, such as headers and message rows.

One wording detail matters in senior interviews: LazyColumn follows principles similar to RecyclerView, but it does not recycle ViewHolder objects. It can reuse compositions for compatible content.

Common follow-up: keys do not make a slow row fast. Profile expensive item content, image loading, and frequent state reads separately.

Everyday UI

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.

Tooling

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.

Use it in practice

Common implementation choices, debugging, and trade-offs.

Mental model

When would you choose Compose, Views, or a mixed UI?
Mid #compose#views#tradeoffs#migration

For a new Android screen, Compose is usually the practical default. It gives the team state-driven UI, Kotlin-only components, flexible layouts, previews, and a strong modern ecosystem.

That does not make every rewrite sensible. Keep or embed Views when:

  • a mature screen works well and has little reason to change
  • a required SDK or specialist component only provides a View
  • migration risk is larger than the product value
  • the team needs to ship a focused change before it can invest in migration

Interop lets the decision happen one boundary at a time:

  • Put Compose inside an existing hierarchy with ComposeView.
  • Put a View inside Compose with AndroidView or AndroidViewBinding.
  • In a Fragment, choose a composition disposal strategy that follows the Fragment view lifecycle.

The cost of a mixed screen is not just rendering. It includes two testing models, focus and accessibility handoff, lifecycle ownership, nested scrolling, theming, and debugging across the boundary. Keep the boundary deliberate and small.

A senior answer should be based on constraints, not toolkit loyalty. Consider the age of the code, team experience, SDK dependencies, performance evidence, test coverage, and expected lifetime of the screen.

“Compose for new work, migrate existing UI when the value justifies it” is a reasonable default. A big-bang rewrite is rarely the only option.

State and recomposition

How does mutableStateOf trigger recomposition?
Mid #compose#state#snapshot#recomposition

mutableStateOf creates observable state that participates in Compose’s snapshot system.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Text("Count: $count")
    Button(onClick = { count++ }) {
        Text("Add")
    }
}

When Text reads count during composition, Compose records that dependency. Writing a different value schedules the scopes that observed it for recomposition. Compose does not simply rerun every composable on the screen.

remember matters because the state holder must survive recomposition. Without it, each run would create a new holder initialized to 0.

The default mutation policy uses structural equality. Assigning a value equal to the old one normally does not invalidate readers. Mutating a plain object inside the state holder is also invisible:

val names = mutableStateOf(mutableListOf("Asha"))
names.value.add("Ben") // the state value was not assigned

Use an immutable list and assign a new value, or use mutableStateListOf when a snapshot-aware mutable collection fits the design.

Senior follow-up: snapshots give reads a consistent view and support precise observation. They do not mean all state can be mutated carelessly from any thread. Concurrent snapshot writes can conflict and application state still needs a clear owner.

What triggers recomposition, and what work can Compose skip?
Mid #compose#performance#state#recomposition

Recomposition is Compose rerunning composable code that may now describe stale UI. The usual trigger is a write to observable state that was read during composition.

@Composable
fun Greeting() {
    var name by remember { mutableStateOf("Asha") }

    Text("Hello, $name")
    Button(onClick = { name = "Ben" }) {
        Text("Change name")
    }
}

Compose records the read of name. When the value changes, it schedules the recomposition scope that observed it. It does not blindly recreate the whole screen.

Three terms are easy to mix up:

  • Scheduled means Compose knows a scope may need another run.
  • Recomposed means the composable code ran again.
  • Skipped means Compose reused the previous result because the call’s inputs met the skipping rules and were unchanged.

Keep recomposition cheap by keeping I/O and expensive calculation out of the composable body. Read state in the smallest useful scope, provide stable item identity, and defer a read to layout or drawing only when the value affects that later phase.

Do not chase a zero recomposition count. A small, cheap recomposition is often the intended update path. Investigate when traces show missed frames, expensive work, or a much wider invalidation than the state change requires.

Senior follow-up: stability affects whether calls can be skipped, and strong skipping allows restartable composables with unstable parameters to be skipped when their parameter comparisons pass. It does not stop state reads inside the scope from invalidating that scope.

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

A composable instance has three lifecycle events:

  1. It enters the composition.
  2. It recomposes zero or more times.
  3. It leaves the composition.

Recomposition is not a lifecycle callback like onStart. It is the runtime reevaluating UI that may be out of date. Compose may skip a call when its inputs have not changed, so business logic must not depend on how often the function runs.

This lifecycle explains the common APIs:

  • remember keeps a value while that call remains in the composition.
  • LaunchedEffect starts a coroutine on entry, restarts when a key changes, and cancels when it leaves.
  • DisposableEffect provides onDispose for unregistering a listener or releasing another resource.
  • rememberCoroutineScope returns a scope cancelled when its call leaves.
@Composable
fun LocationObserver(client: LocationClient) {
    DisposableEffect(client) {
        val listener = client.addListener { /* update state */ }
        onDispose { client.removeListener(listener) }
    }
}

Identity is part of the story. Calls at different positions are different instances. In a changing list, stable keys help Compose keep an instance and its remembered state attached to the correct item.

For an interview, connect the lifecycle to a real bug: work launched directly in the composable body may run again on recomposition, while a listener without onDispose can outlive the UI that registered it.

What are the three Compose phases, and why do they matter?
Mid #compose#phases#performance

Compose updates UI through three main phases:

  1. Composition decides what UI exists by running composable functions.
  2. Layout measures each element and decides where it goes.
  3. Drawing renders pixels.

The useful part is not memorizing the order. It is knowing that state reads are tracked in the phase where they happen. A change can restart only the phase that needs new work.

// Reads offset during composition
Box(Modifier.offset(x = scrollOffset.dp, y = 0.dp))

// Reads offset during layout
Box(
    Modifier.offset {
        IntOffset(x = scrollOffset, y = 0)
    }
)

For rapidly changing pixel values, the lambda overload can avoid composition and go straight to layout. A value used only for color or a canvas operation can often be read in a draw modifier.

Do not force every state read into a later phase. If a value changes which composables exist, composition is exactly where it belongs. If it changes size, layout still has to run.

A solid performance answer: identify what the state changes, find where it is read, and check which phase actually needs to rerun. Then measure in a release build before rewriting code.

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

On Android, the usual choice is collectAsStateWithLifecycle():

@Composable
fun FeedRoute(viewModel: FeedViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    FeedScreen(
        state = uiState,
        onRetry = viewModel::retry,
    )
}

It converts a Flow into Compose State, so reading uiState participates in recomposition. Collection is active only while the lifecycle is at least the configured state, STARTED by default.

collectAsState() is tied to the composition but not to an Android lifecycle. That makes it appropriate for platform-independent Compose code. On Android, it can keep collecting while an Activity is stopped but its composition still exists.

The lifecycle-aware version pairs well with a StateFlow exposed by the ViewModel:

val uiState = repository.feed
    .map(::toUiState)
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = FeedUiState.Loading,
    )

When the screen stops collecting, WhileSubscribed can eventually stop the upstream work as well.

Keep the route and content split in mind. A route collects state and connects the ViewModel; a stateless FeedScreen receives values and callbacks. That keeps previews and UI tests simple.

For one-off events, do not automatically put consumable flags into the state and reset them from composition. Model durable screen state as state, and handle transient events with a lifecycle-aware design suited to their delivery needs.

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 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.
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.

What is CompositionLocal, and when should or shouldn't you use it?
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.”

Effects and lifecycle

How do you choose between LaunchedEffect, DisposableEffect, SideEffect, and rememberCoroutineScope?
Mid #compose#side-effects#lifecycle

A composable body should describe UI. When work must escape that body, choose an effect based on what starts it and how it should stop.

  • LaunchedEffect(keys) runs suspend work tied to a call in the composition. It cancels when the call leaves and restarts when a key changes.
  • DisposableEffect(keys) is for registration with matching cleanup, such as a lifecycle observer or callback listener.
  • SideEffect runs after every successful recomposition. It is useful for publishing Compose state to an object not managed by Compose.
  • rememberCoroutineScope() provides a composition-aware scope for event handlers, such as showing a snackbar after a tap.
val scope = rememberCoroutineScope()

Button(
    onClick = {
        scope.launch {
            snackbarHostState.showSnackbar("Saved")
        }
    },
) {
    Text("Save")
}

For a listener, cleanup is the deciding factor:

DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event ->
        analytics.onLifecycleEvent(event)
    }
    lifecycleOwner.lifecycle.addObserver(observer)

    onDispose {
        lifecycleOwner.lifecycle.removeObserver(observer)
    }
}

Avoid calling viewModel.load() or launching a coroutine directly in the composable body. Recomposition can repeat that work.

Quick decision: composition event plus suspend work, use LaunchedEffect; user event plus suspend work, use rememberCoroutineScope; setup and teardown, use DisposableEffect; publish after a successful composition, use SideEffect.

How does LaunchedEffect work, and why do its keys matter?
Mid #compose#side-effects#LaunchedEffect#keys

LaunchedEffect launches a coroutine when its call enters the composition. The coroutine is cancelled when that call leaves. If any key changes, Compose cancels the old coroutine and starts a new one.

@Composable
fun UserRoute(userId: String, viewModel: UserViewModel) {
    LaunchedEffect(userId) {
        viewModel.load(userId)
    }
}

The key states the effect’s restart policy. Here a new user ID means the old load is no longer the right work.

Common mistakes are:

  • Using LaunchedEffect(Unit) while reading a changing input. The effect keeps running with assumptions from its first launch.
  • Adding a value as a key even though a change should not restart the work. A timer can then keep resetting.
  • Using an unstable key that changes on every recomposition, which repeatedly cancels useful work.

When a long-lived effect should keep running but use the latest callback, pair a constant key with rememberUpdatedState:

val currentOnTimeout by rememberUpdatedState(onTimeout)

LaunchedEffect(Unit) {
    delay(5_000)
    currentOnTimeout()
}

LaunchedEffect(Unit) is valid when its lifetime should match that call site, but it deserves the same pause you would give while (true). Make sure “start once for this instance” is really the intended rule.

One final distinction: user events are not composition events. For a click that shows a snackbar, launch from rememberCoroutineScope() inside onClick.

Layout and modifiers

Why does Modifier order change layout, drawing, and touch behavior?
Mid #compose#output-based#modifier

Modifier order is behavior, not formatting. Each element in the chain wraps the elements that follow it.

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

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

In A, the 16 dp outer area is not painted red. padding sits outside the background. In B, the background sits outside the padding, so the padded area is red too.

A good way to read a chain is from left to right as nested wrappers:

A: padding(background(size(content)))
B: background(padding(size(content)))

The same rule changes interaction:

Modifier.clickable { }.padding(16.dp) // padding is inside the click target
Modifier.padding(16.dp).clickable { } // outer padding is not clickable

Size constraints need extra care. size requests a preferred size within the constraints it receives; it is not an unconditional final pixel size. That is why guessing only from the method names can be misleading.

How to answer a modifier puzzle: classify each modifier as layout, draw, input, or semantics; rewrite the chain as wrappers; then work through the constraints and bounds from the outside in.

How do you choose between Canvas, drawBehind, and drawWithCache for custom drawing in Compose?
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.

Lists

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.

Everyday UI

How should text field state be managed in modern Compose?
Mid #compose#textfield#state#input

Compose has value-based and state-based text field APIs. Know both, because many codebases still use the value-based form:

var text by rememberSaveable { mutableStateOf("") }

TextField(
    value = text,
    onValueChange = { text = it },
)

This is controlled input. The field reports an edit, and the caller must provide the updated value on the next composition. Delaying that round trip through asynchronous ViewModel work can cause lost edits or cursor jumps.

For new code, state-based fields are designed to manage text, selection, and IME composition together:

val textState = rememberTextFieldState()

TextField(
    state = textState,
    lineLimits = TextFieldLineLimits.SingleLine,
)

rememberTextFieldState() includes save and restore support. State-based APIs also separate input changes from display transformation, which avoids changing the backing text just to format what the user sees.

Keep immediate editing state close enough to the field to update synchronously. Send meaningful events or a debounced query to the ViewModel when business logic needs it. Do not run network requests or heavy validation directly in an edit callback.

For an interview, also mention selection, IME composition, keyboard actions, accessibility, and restoration. A text field stores more than a String, which is why complex input benefits from TextFieldState.

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.
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.
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(...)
    .anchoredDraggable(...) // state-driven drag between defined anchors
    .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.
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 { }.
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.

App integration

How should navigation, arguments, and ViewModels be handled in Compose?
Mid #compose#navigation#viewmodel

Keep navigation at the edge of a screen. A reusable screen should receive callbacks, not a NavController:

@Composable
fun FeedScreen(
    state: FeedUiState,
    onOpenArticle: (String) -> Unit,
) {
    ArticleList(
        articles = state.articles,
        onArticleClick = { article -> onOpenArticle(article.id) },
    )
}

The navigation host connects that callback to the back stack. In Navigation Compose, prefer type-safe routes where the project version supports them:

@Serializable data object Feed
@Serializable data class Article(val articleId: String)

NavHost(navController, startDestination = Feed) {
    composable<Feed> {
        FeedRoute(
            onOpenArticle = { id ->
                navController.navigate(Article(id))
            },
        )
    }

    composable<Article> { backStackEntry ->
        val route = backStackEntry.toRoute<Article>()
        ArticleRoute(articleId = route.articleId)
    }
}

Pass a small identifier, not a whole domain object. The destination can load the current data from its repository or ViewModel. This avoids stale copies and keeps the back stack’s saved state small.

A destination’s ViewModel is normally scoped to its NavBackStackEntry. It survives recomposition and configuration change, then clears when that entry is removed. Scope a shared ViewModel to a parent graph only when several destinations genuinely own one flow, such as checkout.

Senior discussion points include deep links, multiple back stacks, process recreation, and result delivery. The principle stays the same: make back-stack ownership explicit and keep composables testable without a navigation runtime.

Navigation libraries are evolving, including Navigation 3 for Compose-first apps. State the version and architecture you have used rather than mixing APIs from different generations in one example.

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.

Performance

How do you investigate a slow or janky Compose screen?
Mid #compose#performance#recomposition#benchmarking

Start with evidence. A useful answer is a small investigation plan, not a bag of remember calls.

  1. Reproduce the exact interaction, such as first scroll, list fling, or opening a detail screen.
  2. Measure a release-like, non-debuggable build. Debug Compose performance is not representative.
  3. Capture frame timing with Macrobenchmark or a system trace. Composition tracing can identify expensive composable work.
  4. Use compiler reports and Layout Inspector when the evidence points to unnecessary recomposition or unstable parameters.
  5. Change one cause and measure again.

Common causes include:

  • sorting, parsing, allocation, or I/O in a composable body
  • reading fast-changing state higher in the tree than necessary
  • reading layout-only or draw-only state during composition
  • missing lazy-list keys or incompatible content being reused together
  • image decoding, synchronous binder work, or another cost outside Compose
  • startup code that would benefit from a Baseline Profile

Fix the cause you measured. Cache a genuinely expensive pure calculation with the inputs as remember keys. Use derivedStateOf when a rapidly changing input produces an output that changes much less often. Use lambda modifiers such as offset { ... } when the state only affects a later phase.

Do not treat recomposition count as a score. Recomposition is normal and often cheap. The problem is expensive work on a critical frame, invalidating too much UI, or rerunning a phase that was avoidable.

Senior follow-up: include startup, memory allocation, image loading, and the View or platform boundary in the trace. A screen can be janky even when its Compose stability report looks perfect.

Optional deep dives

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

Effects and lifecycle

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.

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.

Layout and modifiers

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).

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.

Performance and runtime

What does stability mean in Compose, and how does strong skipping change the answer?
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.

Stability tells the Compose compiler whether it can reliably detect changes to a value. A stable type has a consistent equality contract, and Compose can observe changes to any mutable public state it exposes.

Examples that are normally stable include primitives, String, function types, and immutable data classes whose properties are also stable. Standard List, Set, and Map interfaces are treated as unstable because the compiler cannot prove that an implementation is immutable.

Strong skipping changes an older interview answer. Since Kotlin 2.0.20 it is enabled by default. Restartable composables with unstable parameters can still be skipped. Stable parameters are compared with object equality, while unstable parameters are compared with instance equality.

@Composable
fun Feed(items: List<FeedItem>) { /* ... */ }

List is unstable, but with strong skipping Feed can be skipped when the exact same list instance is passed again. A newly allocated equal list is a different instance, so it will not be skipped on that basis.

Possible improvements include:

  • expose truly immutable models
  • use kotlinx.collections.immutable when it fits the codebase
  • use a stability configuration for types whose contract you control
  • add @Immutable or @Stable only when the type really satisfies that promise

Annotations are contracts with the compiler, not magic fixes. Marking a wrapper @Immutable while it exposes a list that callers can mutate can produce stale UI.

Most importantly, diagnose a real performance issue before redesigning models. Use compiler reports to understand inferred stability, then confirm the cost in a trace or benchmark. An unstable parameter is information, not proof that the screen is slow.

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.
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.

Discussion

Comments are powered by GitHub Discussions. Sign in with GitHub to join in.