Jetpack Compose Interview Questions — Practice Quiz

Reviewed by Mark Dickie · Last updated

Jetpack Compose is Google's declarative UI toolkit for building native Android interfaces using Kotlin composable functions. For an interview, you need to understand how recomposition works, how state flows through a composable tree via remember and StateFlow, and when to use effect APIs like LaunchedEffect and DisposableEffect. You should also know the slotting pattern (content lambdas), how Modifier chaining works, and how to structure themes with MaterialTheme. Expect questions on performance: minimizing unnecessary recompositions, using key and @Stable annotations, and understanding the difference between derivedStateOf and direct state reads.

What does a Jetpack Compose interview test?

AreaWhat to knowCommon question types
RecompositionWhen and why the framework re-executes a composableExplain skip conditions and stability
State managementremember, rememberSaveable, mutableStateOf, StateFlowChoose the right state holder for a scenario
Side effectsLaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScopeLifecycle and cancellation semantics
LayoutColumn, Row, Box, ConstraintLayout, custom LayoutMeasure-and-place, intrinsic measurements
ThemingMaterialTheme, CompositionLocal, custom design systemsOverride or pass theme values without prop drilling
Performancekey, @Stable, @Immutable, derivedStateOfDiagnose excess recompositions

How should you prepare?

  1. Build a small app that mixes StateFlow from a ViewModel with local remember state so you can explain the boundary between screen-level and composable-level state.
  2. Write a custom Layout composable from scratch. Interviewers often ask you to implement a simple flow layout or grid to test whether you understand the measure-inplace phase.
  3. Review every effect API and be able to say when each one runs, what cancels it, and what happens on recomposition. Mixing up LaunchedEffect and SideEffect is a common mistake that interviewers probe.
  4. Read the Compose stability section of the official docs so you can explain why a List<String> parameter might force recomposition and how wrapping it in a stable class fixes it.
  5. Practice explaining the composable lifecycle (initialization, composition, recomposition, disposal) because that framing ties together almost every other topic on this list.

Key facts

  • Tarmac has 94 Jetpack Compose interview questions on this topic, 25 of them on this page, at difficulty 1–5 of 5.
  • Tarmac last reviewed these Jetpack Compose interview questions on 31 August 2026.

At a glance

Questions25 shown · 94 in the bank
Difficulty1–5 of 5
FormatsTrue / false, Flashcard, Code output, Fill in the blank, Multiple choice, Multiple answer, Ordering, Find the bug, Short answer

What you'll review

  1. stability
  2. recomposition skipping
  3. modifier chains
  4. lazy lists performance
  5. view interop
  6. composable functions
  7. state hoisting
  8. remember rememberSaveable
  9. launched effect
  10. derived state of
  11. disposable effect

Practice questions

Try one before you open the answer. Pick an option and press Check; it's marked on the spot.

Jetpack Compose/recomposition/stability

In Jetpack Compose, a data class whose properties are all val and all of stable types (e.g., Int, String, other stable data classes) is inferred as stable by the Compose compiler, which allows the compiler to mark composables receiving it as skippable.#

Options

Show answer

True. The Compose compiler automatically infers a data class as stable when every property is a val of a stable type — primitives like Int, String, and Boolean are inherently stable. Because the type is stable, the compiler can mark composables that receive it as skippable, so they are skipped during recomposition when the argument value has not changed.

Why:

The Compose compiler infers stability for a type when all of its public properties are val and their types are themselves stable. Primitives (Int, Boolean, etc.) and String are stable by definition, so a data class composed entirely of val stable-typed properties is stable. This stability lets the compiler generate skippable composables, meaning a composable whose stable arguments are unchanged can be skipped during recomposition.

Jetpack Compose/recomposition/recomposition-skipping

What is recomposition in Jetpack Compose?#

Show answer

Recomposition is Compose re-invoking a composable function (or a subset of the composables in a composition) to update the UI when the State it reads changes. It is not a full re-render of the whole screen from scratch: Compose tracks which State a composable reads during composition, and when that State changes, only composables that actually read it are scheduled to recompose. Compose can also skip recomposing a composable entirely if its inputs are stable and unchanged, which is why writing composables with stable, minimal parameters matters for performance.

Why:

Recomposition is the mechanism that makes Compose declarative and reactive at the same time: you describe what the UI should look like given the current state, and Compose figures out which parts need to be re-invoked when that state changes, rather than the developer manually mutating Views. Understanding that it's targeted (only readers of changed state) and skippable (when inputs are stable and unchanged) rather than a full-tree re-render is the foundation for every Compose performance question that follows — stability, state hoisting granularity, and why unnecessary object allocation inside a composable is expensive.

Jetpack Compose/composable-fundamentals/modifier-chains

In Jetpack Compose, in what order are modifiers in a Modifier chain applied, and which end of the chain is the "outermost" modifier?#

Show answer

Modifiers are applied left to right. The first modifier is outermost (affects sizing/padding around the element), and the last modifier is innermost (closest to the composable content). This means Modifier.padding(16.dp).background(Color.Red) draws red behind the padding area, while Modifier.background(Color.Red).padding(16.dp) draws red only inside the padding.

Why:

Modifier order is left-to-right, with the leftmost modifier wrapping the outermost layer. This is a fundamental Compose concept: reversing the order of modifiers like padding and background produces visibly different results because each modifier wraps the result of everything to its right.

Jetpack Compose/composable-fundamentals/modifier-chains

What does an empty Modifier (just Modifier with no chained calls) do when applied to a composable?#

Show answer

The empty, default Modifier object — i.e., Modifier with no chained calls — is effectively a no-op. It applies no transformations, so passing it (or omitting a modifier entirely) leaves the composable's size, appearance, and behavior unchanged.

Why:

Modifier is the companion object's empty implementation; it passes through all measurements and drawing untouched. It serves as the neutral identity element for modifier composition.

Jetpack Compose/compose-performance/lazy-lists-performance

The code below models what happens inside a Jetpack Compose LazyColumn when you call items() without a key parameter and the list is reordered. In Compose, remember inside an item slot is tied to the position in the list, not to the item's identity. What does this program print?#

// Simulates a LazyColumn WITHOUT the key parameter.
// In Compose, remember() inside items() is tracked by POSITION
// when no key is provided. This code models that behavior.

fun main() {
    // remember() values keyed by slot position
    val slotState = mutableMapOf<Int, String>()

    // First composition: list = [Alice, Bob, Carol]
    listOf("Alice", "Bob", "Carol").forEachIndexed { i, name ->
        slotState[i] = name
    }

    // List update: Bob moves to the front -> [Bob, Alice, Carol]
    val reordered = listOf("Bob", "Alice", "Carol")

    // Without key, each position reuses the state at that position
    reordered.forEachIndexed { i, name ->
        println("Slot $i shows '${slotState[i]}', expected '$name'")
    }
}
Show answer
Slot 0 shows 'Alice', expected 'Bob'
Slot 1 shows 'Bob', expected 'Alice'
Slot 2 shows 'Carol', expected 'Carol'

Why:

Without a key, Compose stores remembered state by slot position. On the first pass, position 0 stores "Alice", position 1 stores "Bob", and position 2 stores "Carol". After reordering to [Bob, Alice, Carol], each position still holds its original value: slot 0 has "Alice" (now expecting "Bob"), slot 1 has "Bob" (now expecting "Alice"), and slot 2 has "Carol" (still "Carol"). This is exactly why providing a stable key to items() is recommended — it lets state follow the item instead of the position.

Jetpack Compose/compose-performance/lazy-lists-performance

Unlike a Column that eagerly composes every child, a LazyColumn only composes the items currently visible on screen (plus a small prefetch buffer). The code below models this behavior for a list of 5,000 items where 6 fit on screen and Compose prefetches 3 more. What does it print?#

// Models how LazyColumn only composes items
// that are visible (plus a small buffer), NOT the entire list.

fun main() {
    val totalItems = 5000
    val visibleOnScreen = 6
    val beyondBoundsItems = 3 // Compose prefetches a few extra

    val composedCount = visibleOnScreen + beyondBoundsItems

    println("List size: $totalItems")
    println("Composables created: $composedCount")
    println("Composables skipped: ${totalItems - composedCount}")
}
Show answer
List size: 5000
Composables created: 9
Composables skipped: 4991

Why:

LazyColumn is called "lazy" because it only instantiates composables for items in or near the viewport. With 6 visible items and 3 prefetched beyond the bounds, only 9 composables are created out of 5,000. The remaining 5,000 − 9 = 4,991 items are never composed until the user scrolls to them. This is the core performance advantage of lazy lists over eager layouts like Column with forEach.

Jetpack Compose/interop/view-interop

The Jetpack Compose composable function used to embed an existing Android View (such as a TextView, MapView, or RecyclerView) inside a Compose tree is _____.#

Show answer

The Jetpack Compose composable function used to embed an existing Android View (such as a TextView, MapView, or RecyclerView) inside a Compose tree is **AndroidView**.

Usage example:

**AndroidView**(
    factory = { context -> TextView(context) },
    update = { tv -> tv.text = "Hello" }
)
Why:

AndroidView is the Compose interop API that lets you bring a classic android.view.View into a composition. You supply a factory lambda that creates the View and an update lambda that configures it on recomposition. It lives in androidx.compose.ui.viewinterop (re-exported from androidx.compose.ui.platform).

Jetpack Compose/interop/view-interop

To embed a Jetpack Compose UI inside a traditional XML-based View hierarchy, you use a _____ widget in your layout XML and then call its setContent { ... } method from Kotlin/Java code.#

Show answer

To embed a Jetpack Compose UI inside a traditional XML-based View hierarchy, you use a **ComposeView** widget in your layout XML and then call its setContent { ... } method from Kotlin/Java code.

Example in XML:

<androidx.compose.ui.platform.**ComposeView**
    android:id="@+id/compose_view"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
Why:

ComposeView is the bridge widget that lets you host Compose content inside an existing ViewGroup. It is an AbstractComposeView subclass; you add it to an XML layout (or create it programmatically) and call setContent { @Composable () -> Unit } to provide the composable tree.

Jetpack Compose/composable-fundamentals/composable-functions

Why is it important that a @Composable function be free of side effects and safe to call multiple times, in any order, or not at all?#

Options

Show answer

A @Composable function must be free of side effects because the Compose runtime is free to call it again during recomposition, skip calling it entirely when its inputs are unchanged, or execute independent composables in a different order or in parallel. A composable that mutates shared state or performs I/O directly can end up running that side effect the wrong number of times or on the wrong thread as a result, which is why Compose provides a dedicated, opt-in surface for side effects such as LaunchedEffect, SideEffect, and DisposableEffect instead of allowing them inline.

Why:

Compose's whole performance model depends on being free to treat composable functions like a description of UI rather than a sequence of imperative steps: recomposition can re-invoke a composable, smart recomposition can skip invoking it at all when its parameters haven't changed, and independent composables can in principle be composed out of order or concurrently. If a composable directly performs a side effect — incrementing a counter, writing to a file, making a network call — none of those guarantees hold anymore: skipping the call skips the side effect unexpectedly, and a re-invocation duplicates it. This is exactly why Compose has a dedicated, opt-in surface for side effects (LaunchedEffect, SideEffect, DisposableEffect) instead of letting composables perform them inline — those APIs give the runtime an explicit place to control when the effect actually runs. Kotlin's compiler does not forbid a composable from touching a var (b is false), returning Unit has no bearing on this at all (c), and visibility modifiers are unrelated to the Compose runtime's execution guarantees (d).

Jetpack Compose/state-management/state-hoisting

What is "state hoisting" in Jetpack Compose?#

Options

Show answer

State hoisting is the pattern where a composable takes its current state as a parameter, such as value, and reports changes via a callback parameter such as onValueChange, instead of owning a mutable state object internally. That moves ownership of the state up to whichever caller decides where it actually lives, which is what keeps the composable itself stateless, reusable, and easy to test — Compose's own built-ins like TextField follow this same value/onValueChange pattern.

Why:

State hoisting is the core pattern Compose uses to keep composables stateless, reusable, and testable: instead of a composable owning mutableStateOf internally (which makes it a one-off, hard-to-reuse, hard-to-test unit tied to its own state), it accepts the current value and an onValueChange (or similarly-named) lambda as parameters, and the caller decides where that state actually lives — in a remember block one level up, in a ViewModel, wherever's appropriate. This is exactly analogous to a 'controlled component' pattern in other declarative UI frameworks, and it's why Compose's own built-ins like TextField take value/onValueChange rather than managing their own text internally. It is a manual authoring pattern the engineer applies, not something the compiler does automatically (b), has nothing to do with centralizing state in Application (c), and it's an active, current pattern rather than something rememberSaveable replaced — rememberSaveable solves a different problem (surviving process death), and hoisted state can itself be backed by rememberSaveable (d is false).

Jetpack Compose/composable-fundamentals/modifier-chains

Given Modifier.padding(16.dp).background(Color.Red) versus Modifier.background(Color.Red).padding(16.dp) applied to the same Box, what's the actual visual difference?#

Options

Show answer

Modifier.padding(16.dp).background(Color.Red) shrinks the layout space by the padding first and then draws the red background only within that smaller area, so no red shows in the margin. Modifier.background(Color.Red).padding(16.dp) draws the red background across the full original size first and then insets the content by the padding, leaving a visible red margin around the content. Each element in a modifier chain wraps and affects everything that comes after it, which is why the order genuinely changes the rendered result rather than being interchangeable.

Why:

Each element in a Modifier chain wraps the next one, and they're applied in the order they're written, left to right — each one affects the size/position/drawing available to whatever comes after it in the chain. Modifier.padding(16.dp).background(Color.Red) first shrinks the available layout space by the padding, and only then draws the background within that already-shrunk area, so you get red with no margin around it (the padding is 'outside' the red, i.e. background-colored the same as whatever's behind the Box). Modifier.background(Color.Red).padding(16.dp) draws the background across the full, unshrunk size first, and only then insets the content by the padding — so the red extends into what looks like a visible red margin around the content. This is one of the most commonly misunderstood parts of Compose for engineers coming from a View/XML background, where attribute order in a layout doesn't carry this kind of meaning, and it's a frequent live-coding interview probe for exactly that reason.

Jetpack Compose/state-management/remember-rememberSaveable

Which of these statements about remember and rememberSaveable are accurate? Select all that apply.#

Options

Pick every one that applies.

Show answer

remember survives recomposition within the same composition but does not survive a configuration change or process death, because the composition itself doesn't survive those. rememberSaveable closes that gap by writing into the same saved-instance-state Bundle the Android framework already uses, which is why it survives both, but a type stored that way must be natively Bundle-storable or backed by a custom Saver. Neither mechanism persists state permanently across an app being fully closed and relaunched days later, and remember specifically prevents recreating its value on every recomposition, unlike a plain local val.

Why:

remember caches a value across recompositions of the same composition — the whole reason it exists is so a composable doesn't recreate expensive or stateful objects on every recomposition pass (a) — but a composition itself doesn't survive a configuration change or process death, so a plain remembered value is lost in both of those cases. rememberSaveable closes that gap specifically by writing into the platform's existing saved-instance-state Bundle, which the OS persists across both configuration changes and process death, restoring it on the next launch (b); because that mechanism is Bundle-based, only types Bundle can natively store (or a type with a registered Saver telling Compose how to serialize/deserialize it) can be stored this way (c). Neither survives an app being fully closed and relaunched days later with no process retained by the system at all — the saved-instance-state Bundle mechanism is for restoring the most recent session's state, not permanent storage, so (d) is false. And (e) misdescribes exactly the problem remember solves: unlike a plain local val, which genuinely is recreated on every recomposition, remember is specifically what prevents that recreation.

Jetpack Compose/side-effects/launched-effect

Which of these statements about LaunchedEffect are accurate? Select all that apply.#

Options

Pick every one that applies.

Show answer

LaunchedEffect runs its suspend block in a coroutine scoped to the composition, automatically cancelled when the composable leaves composition, and it re-keys on its parameters: when a key changes between recompositions, the running coroutine is cancelled and a new one launches with the new key. That is why LaunchedEffect(Unit) launches once on first entering composition and never relaunches on later recompositions, since its single key never changes — it does not re-run on every recomposition, and it is called directly from a composable's body with no requirement to be nested inside another LaunchedEffect.

Why:

LaunchedEffect exists to bridge composition with coroutines: it launches its block in a CoroutineScope tied to the composable's presence in the composition, and that scope is cancelled automatically when the composable is removed from composition (a) — that's what makes it safe to launch, say, a network request or an animation without manually managing cancellation. It re-keys on its parameters: whenever a key changes between recompositions, Compose cancels the in-flight coroutine and starts a fresh one with the new key values (b), which is exactly why LaunchedEffect(Unit) — a key that is, by construction, always the same, unchanging value — only ever launches once, on first entering composition, and never restarts on later recompositions (c). It is explicitly not tied to every recomposition the way (d) claims; that's the entire point of keying it, since re-running a side effect on every single recomposition (which can happen many times) would be exactly the runaway behavior LaunchedEffect's key mechanism is designed to prevent. And (e) is simply false — LaunchedEffect is a standard composable function called directly in a composable's body, most commonly gated behind some condition, with no requirement to be nested inside another one.

Jetpack Compose/interop/view-interop

In the AndroidView composable, the factory lambda runs on every recomposition to keep the wrapped legacy View in sync with Compose state, while the update lambda runs only once, when the View is first created.#

Options

Show answer

False. In AndroidView, factory runs exactly once, the first time it enters composition, and its only job is to construct and return the legacy View instance, which Compose then reuses across recompositions instead of recreating. update is the lambda that runs on every recomposition where AndroidView's inputs are read, and its job is to push any changed Compose state onto that already-created View so it stays in sync — mixing the two up is a common source of Compose-View interop bugs.

Why:

False — it's the other way around. factory runs exactly once, the first time AndroidView enters composition, and its only job is to construct and return the legacy View instance; Compose then holds onto that same View instance across recompositions instead of recreating it. update is the lambda that runs on every recomposition where AndroidView's inputs are read, and its job is exactly to push any changed Compose state onto the already-created View (e.g. textView.text = someComposeState.value) so the wrapped View stays in sync with whatever state changed. Mixing these two up is a common source of interop bugs: putting sync logic in factory means the View is only ever configured once and silently goes stale on later state changes, while doing expensive construction work in update means it needlessly re-runs on every recomposition.

Jetpack Compose/side-effects/launched-effect

A composable calls LaunchedEffect(userId) { loadProfile(userId) }. Order what happens from the composable first entering composition through userId changing once and the composable eventually leaving composition.#

Put these in order

Show answer

For LaunchedEffect(userId) { loadProfile(userId) }, the sequence is: the composable enters composition, LaunchedEffect launches a coroutine with the initial userId, a later recomposition changes the userId key, the in-flight coroutine for the old userId is cancelled, a new coroutine launches with the new userId, and finally the composable leaves composition, cancelling whichever coroutine is running at that point. The key change has to precede cancellation and relaunch because LaunchedEffect only restarts in response to its key actually changing, not on every recomposition.

Why:

Nothing can launch before the composable exists in composition, so entering composition comes first, immediately followed by the first launch with whatever userId was passed in initially. LaunchedEffect only reacts to its key changing on a later recomposition — it doesn't restart when unrelated state changes — so the key change has to happen before anything is cancelled or relaunched. When Compose sees the key genuinely changed, it must cancel the previous coroutine before starting the new one, both to avoid two concurrent loadProfile calls racing each other and because that's literally what re-keying means: the old effect is no longer valid for the new key. The final cancellation on leaving composition is a separate, later event triggered by composable removal rather than a key change, so it comes last and applies to whichever coroutine happens to be running at that point.

Jetpack Compose/composable-fundamentals/composable-functions

Which of the following statements about @Composable functions in Jetpack Compose are true? (Select all that apply.)#

Options

Pick every one that applies.

Show answer

@Composable functions can accept @Composable lambdas as parameters, and the @Composable annotation produces a distinct function type that is not interchangeable with a plain function type. They do not have to return Unit (e.g., remember returns a value), and they cannot be invoked from non-composable code because the compiler injects a Composer argument that must be supplied in a composable context.

Why:

Option (a) is true — composable functions routinely take @Composable () -> Unit lambda parameters (e.g., Column's content slot). Option (d) is true — the compiler transforms the function type so that @Composable () -> Unit is a distinct type from () -> Unit; they are not interchangeable. Option (b) is false — composable functions can return any type; remember is a canonical example of a @Composable function that returns a value. Option (c) is false — calling a @Composable function requires a Composer instance that the compiler injects, so it can only be invoked inside a composable context (such as setContent or another @Composable function).

Jetpack Compose/side-effects/derived-state-of

Wrapping a computed value in remember { derivedStateOf { ... } } is useful specifically when the derived result changes less often than the raw state it's computed from, because readers of the derived value then only recompose when the result actually changes, not every time an input changes.#

Options

Show answer

True. derivedStateOf recomputes its lambda whenever an input State it reads changes, but it only reports a new value, and therefore only triggers recomposition in whatever reads it, when the recomputed result is actually different from the last one. A scroll-derived boolean is the classic example: the scroll offset changes on every pixel, but a derivedStateOf wrapping a threshold check only causes recomposition on the two occasions the boolean actually flips, rather than on every scroll update.

Why:

True. A plain computation re-run directly inside a composable's body reads its inputs as State, so the composable recomposes every time any of those inputs change, even if the value it actually cares about — the result of the computation — didn't change. A classic example is a scroll-derived boolean like listState.firstVisibleItemIndex > 0: the scroll offset changes on every pixel of scroll, but the boolean only flips twice, once crossing 0. derivedStateOf recomputes its lambda whenever an input State it reads changes, but it only reports a new State value — and therefore only triggers recomposition in whatever reads it — when that recomputed result is actually different from the last one. This makes it purely a performance tool for the case where read-frequency of the inputs and the result differ, not a general substitute for remember; using it when the result changes just as often as the input adds overhead for no benefit.

Jetpack Compose/compose-performance/lazy-lists-performance

Users report that when they swipe an item to delete it from this list, the wrong item's expanded/collapsed state and text-field input sometimes end up attached to a different row after the delete. Which line is the root cause?#

1| LazyColumn {
2|     items(tasks) { task ->
3|         var expanded by remember { mutableStateOf(false) }
4|         TaskRow(
5|             task = task,
6|             expanded = expanded,
7|             onToggle = { expanded = !expanded },
8|             onDelete = { onDeleteTask(task.id) },
9|         )
10|    }
11| }

Options

Show answer

Line 2 — items(tasks) is called with no key lambda, so Compose identifies each row's slot by its position in the list rather than by the identity of the underlying task; when an item is removed, every row after it shifts up a position and inherits the remembered state (like expanded on line 3) that belonged to whatever was previously at that position

Why:

items(tasks) { ... } without a key parameter identifies each composed row by its index in the list, not by which task it actually represents. Each row's remembered state (expanded here) is stored keyed to that slot, not to the task. When a task is deleted from the middle of the list, everything after it shifts up by one position — but the positions' remembered state doesn't shift with the data; instead, whatever expanded value was remembered at position N stays at position N and is now attached to a different task's row, which is exactly the 'wrong item's state shows up on a different row' symptom described. The fix is items(tasks, key = { it.id }) { task -> ... }: giving Compose a stable identity lets it match remembered state to the same underlying task across insertions, deletions, and reorders, discarding state cleanly (rather than misattributing it) when a task is actually removed. Removing remember entirely (b) would just make the state reset on every recomposition instead of persisting correctly for the right row, which fixes the symptom by breaking the underlying feature; the toggle lambda on line 7 is a correct, idiomatic read-and-flip of the captured mutableStateOf (c is a non-issue); and index-based keying by default is real, but it is exactly the behavior causing this bug, not an unrelated 'no bug' situation (d).

Jetpack Compose/recomposition/recomposition-skipping

Profiling shows that ExpensiveRow recomposes on every keystroke typed into an unrelated search field elsewhere on the same screen, even though ExpensiveRow never reads the search text. Which line causes the unnecessary recomposition?#

1| @Composable
2| fun ScreenContent(searchText: String, item: Item) {
3|     Column {
4|         SearchField(value = searchText, onValueChange = ::onSearchChanged)
5|         ExpensiveRow(
6|             item = item,
7|             onClick = { logClick(item.id, searchText) },
8|         )
9|     }
10| }

Options

Show answer

Line 7 — the lambda passed as onClick captures searchText from the enclosing scope, so a new lambda instance is created on every recomposition of ScreenContent (which happens on every keystroke, since searchText changed); that new, non-stable-by-instance lambda is passed as a changed parameter to ExpensiveRow, defeating its ability to skip recomposition

Why:

Every keystroke changes searchText, which recomposes ScreenContent — that part is expected and correct. The bug is what that recomposition does to ExpensiveRow's inputs: the trailing lambda on line 7 closes over searchText, so Kotlin has to allocate a new lambda instance capturing the new searchText value on every recomposition of ScreenContent, even though item itself hasn't changed. Compose's smart recomposition decides whether to skip re-invoking ExpensiveRow by checking whether all of its parameters are stable and equal to their previous values by instance/structural equality — and a freshly-allocated lambda instance is, from that check's point of view, a changed parameter, so ExpensiveRow is forced to recompose even though the only thing that actually changed lives outside of it entirely. The general fix is to avoid capturing frequently-changing values in a lambda passed down into a component that shouldn't care about them — e.g. hoist the click handling so ExpensiveRow receives a stable callback reference, or pass item.id and searchText separately so the composable can be restructured to not force the row to recompose. item itself being a data class is not inherently a problem (b) — a @Immutable/stable data class of stable-typed vals composes just fine — Compose does not process composables in linear declaration order for recomposition purposes (c), and siblings inside the same parent do not all recompose together automatically; each is independently skippable based on its own parameters (d is false).

Jetpack Compose/side-effects/disposable-effect

When should you reach for DisposableEffect instead of LaunchedEffect, and why does DisposableEffect require an onDispose block?#

Show answer

LaunchedEffect is for launching a coroutine tied to the composition — it's the right tool when the effect itself is asynchronous work, like a network call or a suspend-based animation, because cancelling that coroutine is exactly the cleanup a coroutine-based effect needs. DisposableEffect is for effects that aren't naturally coroutines but still need explicit, synchronous cleanup when the composable leaves composition or a key changes — registering a listener, a BroadcastReceiver, an observer, or anything with a matching subscribe/unsubscribe or attach/detach pair. DisposableEffect requires the block to end with an onDispose { ... } call because without a mandatory cleanup step, effects like registering a listener would leak: the listener would keep firing (and potentially referencing state or a Context that's no longer valid) after the composable that registered it has left composition, with no way for Compose to know how to clean it up. Requiring onDispose as the final statement is a compile-time guarantee that every DisposableEffect declares its own cleanup, rather than leaving it to be forgotten.

Why:

The dividing line is whether the effect is itself a coroutine: LaunchedEffect exists to launch and manage the lifecycle of a suspend-based effect, and cancellation of that coroutine is Compose's natural cleanup mechanism for it. DisposableEffect is for effects that register something outside the coroutine world entirely — a listener, an observer, a callback — where 'cleanup' means explicitly unregistering it, not cancelling a coroutine. Because a registered listener that's never removed is a classic leak (it keeps a reference alive and keeps firing into code that's no longer meant to be running), Compose forces every DisposableEffect to supply an onDispose block as the final statement of its lambda — it's a compile-time requirement precisely so this cleanup step can't be silently omitted.

Jetpack Compose/composable-fundamentals/modifier-chains

In Jetpack Compose, each modifier in a chain wraps all subsequent modifiers, forming nested layers. Given the chain Modifier.fillMaxSize().aspectRatio(1f).clip(CircleShape).padding(8.dp) applied to a composable, order the four modifiers from the outermost wrapping layer (first applied, receives parent constraints directly) to the innermost wrapping layer (last applied, closest to the composable's content).#

Put these in order

Show answer

In a Jetpack Compose modifier chain, the leftmost modifier is the outermost wrapping layer and the rightmost is the innermost. For Modifier.fillMaxSize().aspectRatio(1f).clip(CircleShape).padding(8.dp), the outermost-to-innermost order is fillMaxSizeaspectRatioclippadding. Each modifier wraps all subsequent modifiers and the composable content, so the first modifier receives parent constraints directly and the last sits closest to the content.

Why:

In a Compose modifier chain, modifiers are applied left-to-right, and each modifier wraps all subsequent ones. The first modifier (fillMaxSize, id b) is the outermost layer, directly receiving the parent's incoming constraints. The second (aspectRatio, id d) wraps everything after it. The third (clip, id a) wraps the fourth and the content. The last (padding, id c) is the innermost, sitting closest to the composable's content. The outermost-to-innermost order is therefore: fillMaxSize → aspectRatio → clip → padding (b, d, a, c).

Jetpack Compose/composable-fundamentals/modifier-chains

In Jetpack Compose, layout modifiers in a chain propagate and transform Constraints inward during a single measure pass. Given the chain Modifier.fillMaxWidth().padding(16.dp).wrapContentSize(Alignment.Center) applied to a Text("Hi") composable, order the following by when they receive and transform constraints during the measure pass — from first (receives the parent's incoming constraints) to last (measures the composable content itself).#

Put these in order

Show answer

In Jetpack Compose, layout modifiers propagate constraints from the outermost (first) modifier inward during the measure pass. For Modifier.fillMaxWidth().padding(16.dp).wrapContentSize(Alignment.Center), the constraint propagation order is: fillMaxWidth receives parent constraints first, then padding reduces them by 32 dp, then wrapContentSize measures the child unbounded, and finally the Text content is measured with the final constraints.

Why:

During the layout phase, constraints propagate from the outermost (first) modifier inward. fillMaxWidth() (id c) first receives the parent's constraints and constrains the width to the maximum available. These reduced-width constraints flow to padding(16.dp) (id a), which subtracts 32 dp from both dimensions. The further-reduced constraints reach wrapContentSize (id d), which measures the child unbounded to find its natural size and centers it. Finally, the Text("Hi") content (id b) is measured within wrapContentSize. The order is therefore: fillMaxWidth → padding → wrapContentSize → Text (c, a, d, b).

Jetpack Compose/interop/view-interop

The following Compose function embeds an Android TextView via AndroidView. When the parent recomposes StatusLabel with a new status value, the rendered text never changes. Which line contains the bug?#

@Composable
fun StatusLabel(status: String) {
    AndroidView(
        factory = { context ->
            TextView(context).apply {
                text = status
                textSize = 16f
                setPadding(16, 16, 16, 16)
            }
        },
        update = { view ->
            view.textSize = 16f
            view.setPadding(16, 16, 16, 16)
        }
    )
}

Options

Show answer

Line 6 — text = status is set inside the factory, which executes only once. It should be moved to the update block as view.text = status so the TextView reflects new status values on recomposition.

Why:

The factory lambda of AndroidView runs exactly once—during the initial composition—to create the view instance. Setting text = status there captures only the first value of status. The update lambda runs on the first composition and every subsequent recomposition, so any state-dependent property must be set there. Because update omits view.text = status, a changed status never reaches the TextView. Lines 7 and 12 correctly set a constant textSize in both blocks, and setPadding in update does not interfere with parent-imposed padding. The fix is to remove text = status from the factory and add view.text = status inside update.

Jetpack Compose/recomposition/stability

Explain, at a mechanism level, how the Compose compiler decides whether a composable's recomposition can be skipped, and name at least two concrete things that commonly defeat that skipping in real code.#

Show answer

The Compose compiler plugin analyzes every composable's parameter types at compile time and classifies each as stable or unstable. A type is considered stable when the compiler can guarantee that if two instances are equal by ==, their publicly-observable content can never diverge afterward — true for primitives, Strings, function types with stable captures, and classes where every property is a val of a stable type, or types explicitly annotated @Immutable/@Stable. At runtime, when a composable is about to recompose, Compose compares each new parameter value to the previous one; if every parameter is stable and unchanged by that comparison, the call is skipped entirely rather than re-executed. Common things that defeat this in real code: passing a plain List, Map, or Set (interfaces with no immutability guarantee, so the compiler treats them as unstable even if a genuinely immutable list is behind the reference) instead of an ImmutableList or similar; passing a lambda that captures a value which changes on every recomposition of the caller, so a fresh lambda instance is allocated each time even though its logic hasn't changed; and using a data class from a module the Compose compiler plugin isn't configured to analyze (e.g. a class defined in a plain Kotlin/Java library module without the plugin applied), which the compiler then treats as unstable by default since it can't prove otherwise.

Why:

This is deliberately a staff-level question because it asks for the mechanism, not just the symptom: the compiler statically classifies parameter types as stable/unstable based on whether it can prove equal instances stay content-equal, and at runtime the skip decision is purely an equality check on stable parameters against their previous values. The two most common real-world defeats — collection interfaces with no immutability guarantee, and freshly-allocated lambdas capturing changing values — are exactly the kind of thing a strong senior/staff Compose engineer should be able to name unprompted when debugging a recomposition-count regression, because both are invisible in the code's apparent correctness (the app still works) and only show up as a performance regression under profiling.

Jetpack Compose/composable-fundamentals/composable-functions

In Jetpack Compose, remember stores values in the composition's slot table, associating each stored value with its position in the call graph rather than with a variable name or a key argument. Name this mechanism, and explain what can go wrong when a remember call appears inside a conditional branch (if) whose condition toggles between recompositions. Identify the composable API that resolves the issue by explicitly anchoring a group of composable calls to a stable positional identity in the slot table.#

Show answer

This mechanism is called positional memoization. Compose identifies each remember call by its source position in the call graph, storing the value in the next available slot in the slot table for that position. When a remember sits inside an if block whose condition toggles, the conditional branch is entered and exited across recompositions. When the branch is exited, the slot table entries for that group are removed; when re-entered, the slots are re-allocated from scratch, so remember returns its creator lambda result again rather than the previously stored value — or worse, if the branch is structurally rearranged, slot entries can become misaligned and remember returns a value stored for a logically different call site. The key() composable resolves this: it wraps a block of composable calls in a group keyed by an explicit identity, so the slot table tracks that group's position regardless of surrounding control flow. The keyed group's slots are preserved across composition changes, keeping remember storage stable.

Why:

Positional memoization is Compose's core mechanism for associating remember storage with call-site identity rather than variable names. The slot table is a linear array indexed by call position; conditional branches add and remove groups, which can misalign slots when control flow changes. key() creates an explicitly identified group that the slot table can track across structural changes, preserving memoized state.

Related interview questions

The other 69 questions

This page shows 25 and marks what you pick. That's as far as a page can go. A free account opens the other 69 and keeps every answer. What you miss comes back until it's right: after a day, then at longer gaps.

Start with this topic

Free · the whole bank · 100 marked answers per 30 days · written feedback on the paid plan

What moved, monthly

One email a month when the bulletin comes out: what moved in the markets we track, and the new question topics we published. Confirm your address to join. Unsubscribe any time.