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?
| Area | What to know | Common question types |
|---|---|---|
| Recomposition | When and why the framework re-executes a composable | Explain skip conditions and stability |
| State management | remember, rememberSaveable, mutableStateOf, StateFlow | Choose the right state holder for a scenario |
| Side effects | LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope | Lifecycle and cancellation semantics |
| Layout | Column, Row, Box, ConstraintLayout, custom Layout | Measure-and-place, intrinsic measurements |
| Theming | MaterialTheme, CompositionLocal, custom design systems | Override or pass theme values without prop drilling |
| Performance | key, @Stable, @Immutable, derivedStateOf | Diagnose excess recompositions |
How should you prepare?
- Build a small app that mixes
StateFlowfrom a ViewModel with localrememberstate so you can explain the boundary between screen-level and composable-level state. - Write a custom
Layoutcomposable from scratch. Interviewers often ask you to implement a simple flow layout or grid to test whether you understand the measure-inplace phase. - Review every effect API and be able to say when each one runs, what cancels it, and what happens on recomposition. Mixing up
LaunchedEffectandSideEffectis a common mistake that interviewers probe. - 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. - 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
| Questions | 25 shown · 94 in the bank |
|---|---|
| Difficulty | 1–5 of 5 |
| Formats | True / false, Flashcard, Code output, Fill in the blank, Multiple choice, Multiple answer, Ordering, Find the bug, Short answer |
What you'll review
- stability
- recomposition skipping
- modifier chains
- lazy lists performance
- view interop
- composable functions
- state hoisting
- remember rememberSaveable
- launched effect
- derived state of
- 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.
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.
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.
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.
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'
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
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" }
)
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" />
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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
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.
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 fillMaxSize → aspectRatio → clip → padding. 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.
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.
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.
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.
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.
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.
Free · the whole bank · 100 marked answers per 30 days · written feedback on the paid plan