Back to articles

Interview Prep

Jetpack Compose Interview Questions — 25 Deep-Dive Questions for Senior Android Roles in 2026

If you’re interviewing for a senior Android role in 2026, you’re going to get a Compose round. Not because every team has fully migrated — many haven’t — but because Compose is now the default and interviewers want to know if you understand it deeply or just enough to copy-paste. The questions that separate strong candidates from weak ones are rarely “what is remember?” — they’re follow-ups three layers deep into recomposition, stability, side effects, and performance.

This post is 25 of those questions. Different shape from my Top 50 Android post — fewer questions, deeper answers, more code where code helps, and explicit framing on what each question is actually testing. If you can hold a 90-second conversation on each of these, the Compose round is yours.


Mental Model & Recomposition (1–5)

1. Walk me through what happens when a state value changes in a composable.

What they’re testing: Whether you understand the snapshot system or just memorized vocabulary.

Compose tracks reads of State<T> objects through a snapshot system. When a composable reads state.value, the runtime records that this composable depends on that state. When the state is written to, every composable that read it is scheduled for recomposition on the next frame. Critically, only the composables that read the state recompose — not the entire tree. Sibling composables that didn’t read it are skipped.

The follow-up they’ll ask: “What does the compiler do to make this skipping possible?” — the compiler analyzes whether each composable’s parameters are stable. If yes and the values haven’t changed, the call is skipped entirely.

2. What does the Compose compiler mean by “stable”?

A type is stable if Compose can rely on equals() to determine whether two instances are equivalent and if its public properties don’t mutate without notifying Compose. Primitives are stable. String is stable (immutable). A data class with all-stable, all-val properties is stable. A List<T> from the standard library is not stable — the compiler can’t prove the underlying list won’t mutate, so it conservatively treats it as unstable.

Why this matters: unstable parameters mean Compose can’t skip recompositions even when the value hasn’t changed. Mark types @Stable or @Immutable when you can prove it, or use ImmutableList from kotlinx.collections.immutable.

// ❌ Unstable — List<T> is treated as unstable by default
data class RouteState(
    val stops: List<Stop>,
    val distance: Int
)

// ✅ Stable — ImmutableList tells the compiler this won’t mutate
data class RouteState(
    val stops: ImmutableList<Stop>,
    val distance: Int
)

3. What’s the difference between “recomposition” and “invalidation”?

Invalidation marks a composable as needing to re-run on the next frame. Recomposition is the actual re-run. They’re separate phases. A composable can be invalidated and then skipped without recomposing if the compiler determines its inputs haven’t changed — that’s the “skipping” behavior. A composable that’s invalidated and has changed inputs goes through recomposition.

4. Why is reading state inside graphicsLayer { } better than reading it directly in a parameter?

Compose has three phases: composition, layout, draw. State reads in composable parameters happen during composition — changing them triggers re-composition. Reads inside graphicsLayer { } happen during the draw phase, which is much cheaper. For values that change every frame (animations, scroll-driven effects), reading them in the lowest possible phase saves work.

// ❌ scroll changes → recomposition every frame
val offset = scrollState.value
Box(modifier = Modifier.offset(y = offset.dp))

// ✅ scroll changes → only the draw phase re-runs
Box(modifier = Modifier.graphicsLayer {
    translationY = scrollState.value.toFloat()
})

5. What’s a recomposition scope and why does it matter?

A recomposition scope is the unit of work Compose can re-run independently. Roughly, every @Composable function is its own scope — if its inputs change, only that function and its children re-run. Inline lambdas inside it (like Box { }’s content) are usually their own scopes too. Understanding scopes is how you reason about “why is this whole screen re-running when only one chip changed” — the answer is usually that the state read happened too high in the tree, putting the whole screen in one scope.


State (6–10)

6. remember vs rememberSaveable — concrete example of when each is wrong.

remember survives recomposition, dies on configuration change or process death. rememberSaveable survives both. The trap with remember: a text field’s scroll position. Looks fine in dev (no rotation), then a tester rotates the phone and loses their place. Use rememberSaveable. The trap with rememberSaveable: storing things that aren’t serializable. The Bundle has size limits and only handles primitives, parcelables, and a small set of types. Trying to rememberSaveable a complex domain object means writing a custom Saver.

7. Where should remember live in the Compose tree, and why?

As close to where the state is used as possible, not at the top of the screen. State at the top of a screen makes the entire screen recompose when it changes. State in the smallest composable that needs it scopes the recomposition.

// ❌ Whole screen recomposes when the search field changes
@Composable
fun TripSearchScreen() {
    var query by remember { mutableStateOf(“”) }
    Column {
        Header()
        SearchField(query, onChange = { query = it })
        ResultsList()  // Doesn’t care about query, but recomposes anyway
    }
}

// ✅ Only the search field subtree recomposes
@Composable
fun TripSearchScreen() {
    Column {
        Header()
        SearchSection()  // Owns its own state internally
        ResultsList()
    }
}

@Composable
fun SearchSection() {
    var query by remember { mutableStateOf(“”) }
    SearchField(query, onChange = { query = it })
}

8. derivedStateOf — when do you actually need it?

When you have a high-frequency state that you’re transforming into a low-frequency state. Classic example: a scroll position that you turn into “is the FAB visible.” The scroll position changes every frame; the FAB visibility flips twice per scroll. Reading scroll > 100 directly causes recomposition every frame. Wrapping it in derivedStateOf means downstream composables only recompose when the boolean actually flips.

val showFab by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
// derivedStateOf is a TOP-LEVEL FUNCTION from compose.runtime
// Returns a State<T> that only emits when the derived value changes

Common wrong answer: using derivedStateOf on every computed value “just to be safe.” If the underlying state is already low-frequency (changes once per user action), derivedStateOf adds overhead with no benefit. It’s specifically for the high-frequency→low-frequency transformation.

9. State hoisting — what does it actually buy you?

Three things: testability (the composable becomes a pure function of inputs), reusability (the same composable works in different contexts because its caller controls behavior), and single source of truth (the state lives in exactly one place, not duplicated across the tree). The pattern is mechanical: replace internal state with two parameters, the value and a callback. var checked: Boolean becomes checked: Boolean, onCheckedChange: (Boolean) -> Unit.

10. StateFlow vs MutableState in a ViewModel — pick one and defend it.

For ViewModel-exposed UI state, StateFlow is the right answer almost always. Reasons: it’s a Flow (composable with other flows, can be mapped, combined, debounced), it has explicit threading semantics (which is important when the ViewModel is touched from coroutines on different dispatchers), and the collectAsStateWithLifecycle bridge handles the lifecycle correctly.

MutableState in a ViewModel works but it’s a Compose-runtime construct leaking into a class that should be UI-toolkit-agnostic. If you ever want to test the ViewModel without Compose runtime, or share it with a non-Compose surface, you’ve coupled the wrong things.


Side Effects & Lifecycle (11–15)

11. LaunchedEffect vs rememberCoroutineScope — when does each fit?

LaunchedEffect(key): runs a coroutine when the composable enters composition; relaunches if key changes; cancelled when leaving. Use for declarative async work tied to composition — “when this screen appears, fetch the route data.”

rememberCoroutineScope: gives you a CoroutineScope tied to the composable’s lifetime. Use for imperative async work triggered by callbacks — “when the user taps the button, launch a coroutine to submit the form.”

// ✅ LaunchedEffect for declarative work
LaunchedEffect(tripId) {
    viewModel.loadTrip(tripId)
}

// ✅ rememberCoroutineScope for imperative work
val scope = rememberCoroutineScope()
Button(onClick = {
    scope.launch { viewModel.bookTrip() }
}) { Text(“Book”) }

Common wrong answer: launching coroutines in the body of a composable directly. That coroutine isn’t tied to anything, leaks every recomposition.

12. DisposableEffect — what problem does it solve that LaunchedEffect can’t?

Cleanup of non-coroutine resources. Sensor listeners, BroadcastReceiver registrations, fragment manager listeners, native handles. The onDispose block runs when the composable leaves composition or the key changes. LaunchedEffect’s coroutine cancellation is the equivalent for coroutines, but doesn’t help when you’re registering with a callback-based API.

DisposableEffect(Unit) {
    val locationListener = LocationListener { update -> ... }
    locationManager.registerListener(locationListener)

    onDispose {
        locationManager.unregisterListener(locationListener)
        // ✅ Symmetric — whatever you register, you must unregister
    }
}

13. SideEffect — what is it and when does it fire?

Runs after every successful recomposition. Used for publishing Compose state to non-Compose code — analytics tracking, sending Compose-managed values to a non-Compose Android API.

SideEffect {
    analytics.setUserProperty(“current_trip_id”, tripId)
    // ✅ Runs after every successful composition
    // Don’t put expensive work here — it fires on every recomposition
}

Common wrong answer: using SideEffect for one-shot work like “send analytics event when screen appears.” That’s a LaunchedEffect. SideEffect fires every composition, not once.

14. What’s wrong with this code?

// ❌ Find the bug
@Composable
fun TripList(viewModel: TripViewModel) {
    val trips by viewModel.trips.collectAsState()

    if (trips.isEmpty()) {
        viewModel.fetchTrips()  // 💥
    }

    LazyColumn { items(trips) { TripItem(it) } }
}

Calling fetchTrips() directly in the body fires on every recomposition where trips.isEmpty(). If the network call is slow, you fire it dozens of times before the first response arrives. Network thrash, race conditions, exhausted retry budgets.

Fix: wrap in a LaunchedEffect.

// ✅ One-shot launch on first composition
LaunchedEffect(Unit) {
    if (viewModel.trips.value.isEmpty()) {
        viewModel.fetchTrips()
    }
}

Even better: move the “fetch on creation” logic into the ViewModel’s init block so the composable doesn’t orchestrate it.

15. collectAsState vs collectAsStateWithLifecycle — which one and why?

collectAsStateWithLifecycle, almost always. collectAsState keeps collecting even when the screen is in the background, which means your StateFlow is doing work no one is consuming — and worse, upstream Flows that depend on it (database queries, network calls) might also keep running.

collectAsStateWithLifecycle stops collection when the lifecycle goes below the configured state (default STARTED) and resumes when it comes back. Better battery, no missed UI updates because the State object retains the latest value while paused.


Performance (16–20)

16. How do you debug an unexpectedly recomposing composable?

Three tools, in order:

(1) Layout Inspector with “recomposition counts.” It shows a number next to each composable indicating how many times it’s recomposed. Numbers climbing while you’re not interacting = a leak somewhere.

(2) The Compose Compiler Reports. Add -P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=... and you get a report listing every composable, whether it’s skippable, restartable, and what parameters are unstable. Unstable parameters are usually the culprit.

(3) Modifier.composed debugging or temporary log statements at known recomposition points to confirm hypotheses.

17. What does @Stable guarantee, and what’s your contract when you apply it?

Applying @Stable tells the compiler: trust me, this type behaves as if stable. Specifically, equals() is consistent (same inputs always return same result), public properties don’t change without notifying Compose (mutations go through State<T>), and reference equality implies content equality.

If you violate this — e.g., you mark a class @Stable but it has a mutable List<T> field that gets mutated in place — the compiler skips recompositions that should have happened, and your UI shows stale data. The bug is silent, hard to find, and the fix is “don’t lie to the compiler.”

18. Why is LazyColumn faster than Column with a verticalScroll for long lists?

LazyColumn only composes and measures items currently in the viewport plus a small buffer. As you scroll, items entering the viewport are composed; items leaving are disposed. Column with verticalScroll composes all items upfront and keeps them alive forever — fine for 20 items, ruinous for 2000. The crossover point in practice is around 50–100 items depending on item complexity.

Senior follow-up: “What does key do in items(list, key = ...) and when is it required?” — it tells Compose how to identify items across recompositions. Without it, Compose uses position, so deleting item at index 0 looks like “every item changed” and forces full re-composition. With a stable key (like an ID), Compose tracks identity correctly and only the deleted item is disposed.

19. remember with no key vs remember(key) { } — when does the key matter?

Without a key, the remembered value lives as long as the composable is in composition. With a key, the value is reset whenever the key changes. Use a key when the remembered value depends on a parameter:

// ❌ The formatter is created once and never updated even if locale changes
val formatter = remember { NumberFormat.getCurrencyInstance(locale) }

// ✅ The formatter is recreated when locale changes
val formatter = remember(locale) { NumberFormat.getCurrencyInstance(locale) }

20. What’s the cost of SubcomposeLayout and when is it worth it?

Each subcomposition pass re-runs the composables in that slot. Twice as much work as a normal Layout, sometimes more. Worth it when you genuinely need to measure children before deciding the parent’s size or constraints — BoxWithConstraints, Scaffold (FAB position depends on bottom bar height), LazyColumn (which only composes visible items). Not worth it for cases a single-pass Layout can handle.


Architecture & Patterns (21–25)

21. How do you structure a screen-level composable in 2026?

The pattern that’s won out:

// Stateful entry point — what the navigation graph routes to
@Composable
fun RideHistoryRoute(
    viewModel: RideHistoryViewModel = hiltViewModel(),
    onRideClick: (String) -> Unit
) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    RideHistoryScreen(
        state = state,
        onRideClick = onRideClick,
        onRefresh = viewModel::refresh,
        onDelete = viewModel::deleteRide
    )
}

// Stateless rendering — preview-friendly, testable
@Composable
fun RideHistoryScreen(
    state: RideHistoryUiState,
    onRideClick: (String) -> Unit,
    onRefresh: () -> Unit,
    onDelete: (String) -> Unit
) {
    when (state) {
        is RideHistoryUiState.Loading -> LoadingView()
        is RideHistoryUiState.Success -> RideList(...)
        is RideHistoryUiState.Error -> ErrorView(state.message, onRefresh)
    }
}

The split: one composable owns the ViewModel and state subscription, another renders state. The renderer is pure-function-of-input, gets a @Preview trivially, tests via Compose UI testing without needing a fake ViewModel.

22. How do you model one-shot events (snackbars, navigation triggers) in Compose?

Not with StateFlow. Events that should fire once need a different abstraction because StateFlow replays its current value to new collectors — rotation will re-fire your snackbar.

Two acceptable patterns. The simpler: a Channel<Event> in the ViewModel exposed as a Flow, collected once per UI lifecycle.

class RideViewModel : ViewModel() {
    private val _events = Channel<UiEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()
    // receiveAsFlow is an EXTENSION FUNCTION on Channel
    // Each event is consumed exactly once

    fun bookRide() = viewModelScope.launch {
        ...
        _events.send(UiEvent.ShowSnackbar(“Ride booked”))
    }
}

The structural alternative: encode events as part of the state, with a “consume” callback the UI calls after handling. More verbose but works better for testing.

23. How does a Compose screen interact with non-Compose Android (Activity, Fragment, Intents)?

Through composition locals and contextual APIs. LocalContext.current gives you the Context. LocalActivity.current gets the host Activity. For starting other Activities or handling results, use rememberLauncherForActivityResult — the Compose-friendly wrapper around the ActivityResult API.

val launcher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.StartActivityForResult()
) { result ->
    if (result.resultCode == Activity.RESULT_OK) {
        viewModel.onPaymentSuccess()
    }
}

Button(onClick = {
    launcher.launch(Intent(context, PaymentActivity::class.java))
}) { Text(“Pay”) }

24. Compose Navigation — how do you pass data between destinations safely?

Three valid options, in order of robustness:

(1) Type-safe routes (Navigation 2.8+): destinations are sealed objects, arguments are typed properties. The compiler catches mismatches.

(2) Argument-based routes: route = "ride/{id}" with type-safe argument extraction. Older but battle-tested.

(3) SavedStateHandle in shared scope: for results returned from one screen to a previous one. Use navController.previousBackStackEntry?.savedStateHandle to write a result before popBackStack; the previous screen reads from its own savedStateHandle.

Common wrong answer: passing complex objects via JSON-encoded strings in route arguments. The route is shown in the address bar of deep links, has size limits, and breaks on rotation. Pass IDs, fetch the data on the destination screen.

25. Hilt + Compose ViewModels — what does hiltViewModel() actually do?

hiltViewModel() resolves a ViewModel from Hilt’s injector, scoped to the nearest ViewModelStoreOwner — which is the NavBackStackEntry for screens inside a NavHost, or the host Activity for screens not in nav. This means each navigation destination gets its own ViewModel instance, scoped correctly, with dependencies injected by Hilt.

The trap: calling hiltViewModel() twice in two different composables on the same screen returns different instances if those composables resolve to different ViewModelStoreOwners. To share a ViewModel between sub-screens (e.g., a multi-step form), pass the same NavBackStackEntry as the owner: hiltViewModel(navController.getBackStackEntry("parent_route")).


How to Use These

If you’re prepping for an interview, don’t memorize these answers. The questions in your interview will have a twist — a slightly different code snippet, a more specific scenario, a follow-up that pushes you off the script. The way to prepare is to understand each topic well enough to riff on it: be able to answer the question, explain why the answer is right, give an example from work, and acknowledge the edge cases or trade-offs.

Three signals interviewers actually use: (1) Do you know the difference between “works” and “works correctly”? Anyone can write code that compiles. Senior candidates know which APIs leak, which patterns cause re-composition storms, which approaches fail at scale. (2) Can you defend a choice when pushed back? Saying “StateFlow because it’s recommended” is a junior answer. Saying “StateFlow because it dedupes via equals, has explicit threading, and integrates cleanly with collectAsStateWithLifecycle” is a senior answer. (3) Do you know what you don’t know? “I haven’t used SubcomposeLayout in production but I know it’s for cases where children measure depends on siblings” is fine. “Sure, SubcomposeLayout is for X” when you’ve never touched it falls apart on the first follow-up.

The rest is reps. Pair these questions with code — build something small, hit the failure modes deliberately, fix them. The questions stop being abstract once you’ve fought them in real code.

Happy coding!

8 views · 0 comments

Comments (0)

No comments yet. Be the first to share your thoughts.