androidinterview.com

Jetpack Compose Interview Questions

46 questions

Tier
Difficulty
Level

Showing all 46 questions

Fundamentals & Declarative UI

Jetpack Compose vs the Android View system: compare them.

Tier: EssentialDifficulty: Easy

The View system is imperative, you build a tree of View objects, usually from XML, then mutate them by hand as your data changes, calling things like findViewById() and setText(). Compose is declarative, you write a function that describes what the UI should look like for the current state, and Compose figures out how to update the screen when that state changes.

// View system
textView.text = "Hello, $name"

// Compose
Text(text = "Hello, $name")

With Views, keeping the UI in sync with your data is your job, you have to remember to call the right setter every time something changes, and it's easy to miss a spot or update a view that's already gone. With Compose, you never touch the UI directly at all, you just update the state, and recomposition handles the rest.

Compose also cuts out a lot of the ceremony that comes with Views, no XML layout files, no findViewById(), no view binding boilerplate, and it's all plain Kotlin, so you get real language features like loops and conditionals directly in your UI code. The two aren't mutually exclusive either, Compose has AndroidView for embedding a legacy View inside a Compose screen, and ComposeView for embedding Compose inside a View based screen, which is how most real apps migrate incrementally instead of rewriting everything at once.

What are Composable functions?

Tier: EssentialDifficulty: Easy

A composable function is a regular Kotlin function marked with the @Composable annotation, and it describes a piece of UI declaratively. Instead of writing code that builds a TextView and sets its text step by step, you just describe what the UI should look like for the current data, and Compose takes care of turning that into actual UI on screen.

@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!")
}

The @Composable annotation isn't just documentation, it tells the Compose compiler plugin to treat this function specially, tracking what state it reads so Compose knows when to call it again. A composable function can only be called from inside another composable function, you can't call one from a regular function or a click listener directly.

Composable functions are also expected to be side effect free and fast, since Compose might call them many times as state changes, skip some of them entirely when their inputs haven't changed, or run them in an order you don't control. You describe the UI, Compose handles rebuilding it.

Explain the lifecycle of a Composable in Jetpack Compose.

Tier: EssentialDifficulty: Medium

A composable's lifecycle has three stages, and that's the whole thing.

  • Entering the Composition. The composable is called for the first time and Compose adds it to the tree.
  • Recomposing. Zero or more times, whenever the state it reads changes, Compose calls it again to update the tree.
  • Leaving the Composition. The composable is no longer called, usually because a condition changed or the caller stopped including it, and Compose removes it from the tree.

That's it, there's no onPause or onStop to override, it's a much simpler lifecycle than an Activity or a Fragment. What decides identity across these stages is the call site, the specific place in the source where a composable is invoked. Two calls to the same composable from two different places in the code are treated as two separate instances, and Compose reuses an instance across recomposition as long as it's called from the same site with the same position.

That positional matching breaks down inside a loop. If you render a list of items by index and the list gets reordered or an item is inserted in the middle, Compose can't tell which instance is which anymore, so it may tear down and recreate instances that should have just moved, restarting any side effects they were running.

Column {
    for (movie in movies) {
        key(movie.id) { // gives the instance a stable identity
            MovieRow(movie)
        }
    }
}

Wrapping each item in key(movie.id) fixes that, it tells Compose to track instances by that key instead of position, so reordering or inserting items moves and reuses composables correctly instead of restarting them. LazyColumn and LazyRow expose the same idea through their own key parameter on items().

Explain the concept of declarative UI in Jetpack Compose.

Tier: CommonDifficulty: Easy

Declarative UI means you describe what the screen should look like for a given state, and let the framework figure out how to get there, instead of writing step by step instructions for mutating a view tree yourself.

@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!") // describes the UI, doesn't mutate anything
}

Conceptually, Compose regenerates the whole screen from scratch every time the state changes, then only applies the parts that actually differ. You never call something like setText() on a specific widget instance. You just call Greeting("Sam") again with new data, and Compose works out that only the text needs to change.

This is the opposite of the imperative model the View system uses, where you hold a reference to a TextView and mutate it directly, and where forgetting to update one view in one code path is a common source of bugs. Declarative UI removes that entire class of bug, because there's no stale reference to forget about, the UI is just a function of the current state.

The tradeoff is that your composable functions have to behave predictably. Since Compose may call them again at any time, skip them, or run them out of order, they need to be fast, idempotent, and free of side effects, so that describing the UI twice in a row always produces the same result.

What are the benefits of Jetpack Compose?

Tier: CommonDifficulty: Easy

Compose's main benefit is that it removes manual view mutation, you describe the UI as a function of state, and recomposition keeps the screen in sync automatically instead of you calling setters on view instances by hand. A few concrete benefits follow from that.

  • Less code. No XML layout files, no findViewById(), no view binding boilerplate, the UI and the logic describing it live in the same Kotlin file.
  • Fewer state bugs. With Views, forgetting to update one widget when data changes is a common source of stale UI. With Compose, you change the state once and everything reading it recomposes on its own.
  • Real language features in your UI. Loops, conditionals, and functions work directly in your UI code, instead of being simulated through XML tricks like include and ViewStub.
  • Built in Material theming. Consistent styling and dark theme support come mostly for free through the Material 3 components.
  • Faster previews and iteration. @Preview renders a composable in Android Studio without running the whole app, which shortens the design feedback loop noticeably.
  • Incremental adoption. AndroidView embeds a legacy View inside Compose, and ComposeView embeds Compose inside a View based screen, so existing apps migrate a screen at a time instead of rewriting everything at once.
@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!") // no findViewById, no setText, just describe it
}

The underlying theme across all of these is that Compose trades imperative, error prone view mutation for a declarative model that's harder to get wrong, at the cost of a real shift in how you think about UI code if you're coming from years of the View system.

What is the difference between declarative UI and imperative UI?

Tier: CommonDifficulty: Easy

Imperative UI means you write step by step instructions for how to change the screen. Declarative UI means you describe what the screen should look like for the current state, and the framework works out how to get there.

// imperative, View system
textView.text = "Hello, $name"
progressBar.visibility = View.GONE

// declarative, Compose
Text(text = "Hello, $name")
if (isLoading) CircularProgressIndicator()

With the imperative style, you hold a reference to a specific view instance and mutate it directly, calling things like findViewById() and setText(). Every code path that can change the UI has to remember to update every affected view, and it's easy to miss one, which is where a lot of "why isn't this updating" bugs come from.

With the declarative style, you never touch a view instance directly. You just change the state, and Compose recalculates what the UI should look like and applies only the differences. There's no stale reference to forget, because the UI function runs fresh off the current state every time.

The cost is a different mental model. You stop thinking in terms of "when X happens, update view Y" and start thinking in terms of "the UI is this function of this state," which takes some getting used to if you're coming from years of View based Android development.

How do you handle lifecycle events in Compose functions?

Tier: CommonDifficulty: Medium

A composable's own lifecycle is just entering, recomposing, and leaving the Composition, it doesn't know anything about onStart or onResume. To react to the surrounding Activity or Fragment's lifecycle, you observe the LifecycleOwner from inside a DisposableEffect.

@Composable
fun TrackScreenTime() {
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) startTimer()
            if (event == Lifecycle.Event.ON_STOP) stopTimer()
        }
        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } // cleanup, always
    }
}

LocalLifecycleOwner.current gets you the owner of whatever screen this composable is part of. DisposableEffect is the right tool here specifically because it pairs setup with mandatory cleanup, you register the observer when the composable enters the Composition, and the onDispose block removes it when the composable leaves, so you never leak a listener attached to a lifecycle that's already gone.

The same pattern covers most cases people reach for lifecycle callbacks in Compose, pausing a video player on ON_PAUSE, unregistering a sensor listener on ON_STOP, or logging screen visibility for analytics. If you just need to run a one shot suspend call when the composable first appears, LaunchedEffect is usually simpler, DisposableEffect is specifically for anything that needs an explicit teardown step.

State & Recomposition

Most Compose interviews spend more time in this section than in all the others put together.

What is remember in Compose, and why and when should you use it?

Tier: EssentialDifficulty: Easy

remember stores a value in the Composition and hands you back that same cached value on the next recomposition, instead of recreating it from scratch every time the function runs. You use it any time a composable needs to hold onto a value across recompositions.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) } // survives recomposition
    Button(onClick = { count++ }) { Text("Count: $count") }
}

Without remember, mutableStateOf(0) would run again on every recomposition and reset count back to zero every time, so clicking the button would never actually appear to do anything. remember is what lets that state persist between one recomposition and the next.

What remember doesn't do is survive a configuration change like a screen rotation, since the whole Composition is thrown away and rebuilt then. For that you'd reach for rememberSaveable, which saves the value into the instance state bundle, or better, put the state in a ViewModel if it needs to survive longer than that.

What is State in Compose?

Tier: EssentialDifficulty: Easy

State in Compose is any value that can change over time and that the UI needs to reflect, like a counter, a text field's contents, or a loading flag. Compose watches state through State<T> and its mutable form MutableState<T>, and when the value changes, Compose automatically recomposes whatever composables read it.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

mutableStateOf(0) creates an observable holder around the value 0. Wrapping it in remember is what makes it survive recomposition, without remember a new MutableState would be created from scratch every time the function reran, and your count would keep resetting to zero. Reading count inside Text is what registers that composable as a subscriber, so only the parts of the UI that actually read count recompose when it changes.

State doesn't have to live inside the composable that displays it. A common pattern is state hoisting, where the state is lifted up to a parent or a ViewModel and passed down as a plain parameter, with an event callback to request changes, which keeps the composable that displays it simple and reusable.

What is the difference between a stateful and a stateless composable?

Tier: EssentialDifficulty: Easy

A stateful composable owns and manages its own state internally, usually with remember. A stateless composable owns no state at all, it just takes everything it needs as parameters and reports changes back through callback lambdas.

// stateful, owns count itself
@Composable
fun StatefulCounter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) { Text("Count: $count") }
}

// stateless, caller owns count
@Composable
fun StatelessCounter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) { Text("Count: $count") }
}

Stateless composables are generally preferred, because they're easier to reuse, easier to test, and easier to preview, since you control their state entirely from outside. This pattern is called state hoisting, you move the state up to the nearest common caller, often a ViewModel, and pass it down as a value plus an event lambda, following what Compose calls unidirectional data flow, state flows down, events flow up.

That said, some state genuinely belongs inside the composable and never needs to leave it, like whether a dropdown is currently expanded. For anything a parent, a sibling, or a ViewModel needs to know about or control, hoist it, for anything purely local to that one piece of UI, keeping it stateful is fine.

What is recomposition?

Tier: EssentialDifficulty: Medium

Recomposition is Compose calling your composable functions again to update the UI, whenever the state they read changes. There's no setText() or notifyDataSetChanged() to call yourself, you just change the state, and Compose figures out what needs to be redrawn.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Clicked $count times") // recomposes whenever count changes
    }
}

Compose is smart about how much it redoes. It only recomposes the composables that actually read the piece of state that changed, and skips the ones whose inputs are the same as last time. So in a screen with a header and a list, changing the list doesn't force the header to recompose too, as long as the header doesn't depend on that state.

Because recomposition can happen often, on every frame during an animation, and Compose doesn't guarantee the order functions run in or even that a composable only runs once per update, your composables need to be fast, side effect free, and idempotent. That means no writing to a shared variable or mutating something outside the function from inside a composable body, since Compose might call it more times than you expect.

What is the difference between remember and rememberSaveable?

Tier: EssentialDifficulty: Medium

remember survives recomposition, rememberSaveable survives recomposition and configuration changes. That's the whole difference, and it comes down to where each one stores the value.

var scrollPosition by remember { mutableStateOf(0) } // lost on rotation

var count by rememberSaveable { mutableStateOf(0) } // survives rotation

remember keeps its value in the Composition itself, in memory. That's enough to survive a recomposition, since the Composition isn't rebuilt for that, but a configuration change like a rotation throws the whole Composition away and builds a new one, so anything held only in remember resets to its initial value.

rememberSaveable writes its value into the same saved instance state Bundle an Activity uses for onSaveInstanceState(), so it comes back after the Composition is rebuilt. The catch is that a Bundle can only hold what Parcelable and a small set of built in types support, primitives, String, and anything you mark @Parcelize. For a custom type that isn't naturally Bundle friendly, you give it a mapSaver or a listSaver to tell rememberSaveable how to serialize and restore it.

@Parcelize
data class City(val name: String, val country: String) : Parcelable

var selectedCity by rememberSaveable { mutableStateOf(City("Madrid", "Spain")) }

Neither one survives the process actually being killed and the task removed from recents, for that you need real persistence, like a ViewModel backed by SavedStateHandle or storage on disk. As a rule, default to remember for anything transient, like a dropdown's open state, and reach for rememberSaveable specifically for state the user would be annoyed to lose on a rotation, like form input or scroll position.

Explain the concept of unidirectional data flow in Jetpack Compose.

Tier: CommonDifficulty: Medium

Unidirectional data flow means state only ever moves down the composable tree, and events only ever move up. A parent holds the state and passes it to children as parameters, children never modify it directly, they report what happened through a callback and let the owner decide what to do about it.

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

Counter doesn't own count, it just displays whatever value it's given and reports the click upward. The caller, often a ViewModel, is the one that actually updates the state, and that new value flows back down on the next recomposition.

This pattern is what makes state hoisting work. Since state always has a single owner and a single direction of travel, you always know where a given value came from and what can change it, instead of chasing mutations happening from several places in the tree.

The payoff shows up when things go wrong. If a value on screen is incorrect, you don't have to search the whole tree for anything that might have written to it, you trace it straight up to the one place that owns it.

How do you avoid recomposition of a composable if its state has not changed?

Tier: CommonDifficulty: Medium

You mostly don't have to do anything, Compose already skips recomposing a composable when its inputs haven't changed. It compares each parameter to its previous value with equals(), and if every parameter is the same and stable, it reuses the last result instead of running the function again. Your job is just not to get in the way of that.

A composable becomes skippable when all of its parameters are stable types. That includes primitives, String, lambdas, and any type Compose can prove is stable, meaning its public properties never change without notifying the Composition. A plain data class built only from stable properties is inferred stable automatically.

@Stable
data class UiState(val isLoading: Boolean, val items: List<Item>)

Where this breaks is unstable parameters. An interface without a @Stable marker, a mutable class with public var properties Compose can't observe, or a plain List<T> passed as a parameter, since List doesn't guarantee immutability, all count as unstable, and Compose has to assume they might have changed even when they haven't. That forces recomposition every time, even for parts of the UI that didn't actually change. Marking a type @Stable or @Immutable when Compose can't infer it, and preferring immutable collections like ImmutableList from kotlinx.collections.immutable over a raw List, is what fixes that.

It's worth knowing that Compose 1.6 and newer ship strong skipping mode, which makes unstable parameters skippable too by comparing them with instance equality instead of disabling skipping outright. It's opt in via the compiler flag on older versions and on by default from Kotlin 2.0's Compose compiler onward, but getting your types genuinely stable is still better than leaning on it, since accurate stability also cuts down on wasted equality checks, not just skipped recomposition.

How do you observe Flows and LiveData states in Compose UI?

Tier: CommonDifficulty: Medium

You observe them with a collector extension that turns the stream into Compose State, and read that state directly in your composable. Compose won't auto recompose from a raw Flow or LiveData, only from something implementing State<T>.

// StateFlow, exposed from a ViewModel
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

// LiveData, mostly legacy code paths at this point
val legacyValue by legacyLiveData.observeAsState()

Text("Count: ${uiState.count}")

collectAsStateWithLifecycle() is the one to reach for in an app module, it collects the Flow while the lifecycle is at least STARTED, and stops collecting when the screen goes to the background, which avoids doing work and holding a collector alive for a screen the user can't see. Plain collectAsState() doesn't do that, it keeps collecting for as long as the composable is in the Composition regardless of lifecycle state, so it's better reserved for library modules that can't depend on lifecycle-runtime-compose.

observeAsState() covers LiveData the same way, it's already lifecycle aware by design, so there's no equivalent lifecycle gap to worry about there, but most new code exposes StateFlow from the ViewModel instead and LiveData shows up mainly in code that hasn't been migrated yet.

Either way, the conversion should happen once, right where the composable reads from the ViewModel, and everything below that point just works with plain Compose State, the same as any other piece of UI state.

How do you retain state across recomposition and configuration changes?

Tier: CommonDifficulty: Medium

For recomposition alone, wrap the value in remember. For configuration changes too, like a rotation, use rememberSaveable instead, or move the state into a ViewModel if it needs to outlive the screen entirely.

var count by remember { mutableStateOf(0) } // survives recomposition only

var name by rememberSaveable { mutableStateOf("") } // survives rotation too

remember caches its value in the Composition, which is enough for recomposition since the Composition itself isn't rebuilt for that. A configuration change is different, it throws the whole Composition away and creates a new one, so a plain remember resets to its initial value. rememberSaveable avoids that by writing into the saved instance state Bundle, the same mechanism onSaveInstanceState() uses, so the value comes back once the new Composition is built.

rememberSaveable only works out of the box with types the Bundle understands, primitives, String, and Parcelable. A custom data class needs @Parcelize, or an explicit mapSaver or listSaver telling it how to serialize and restore the value.

val viewModel: ScreenViewModel = viewModel() // survives configuration change by construction
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

For anything more than a rotation needs to survive, like the state persisting through the Activity being backgrounded and recreated by the system later, put it in a ViewModel. A ViewModel is retained across configuration changes by the framework already, and if you back it with SavedStateHandle, it survives process death too, which is more than either remember or rememberSaveable can do on their own.

How does state management work in Jetpack Compose?

Tier: CommonDifficulty: Medium

State management in Compose comes down to holding values in something Compose can observe, State<T>, and letting recomposition keep the UI in sync automatically whenever that observable value changes. You never push updates to the UI by hand, you just change the state.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) } // observable, survives recomposition
    Button(onClick = { count++ }) { Text("Count: $count") }
}

mutableStateOf creates the observable holder. remember caches it so it isn't rebuilt on every recomposition. Reading count inside Text is what subscribes that composable to changes, so only the composables that actually read count recompose when it changes, not the whole screen.

For state that needs to survive a configuration change, rememberSaveable does the same job but writes into the saved instance state Bundle. For state that needs to live longer than the Composition, or be shared across screens, it belongs in a ViewModel, exposed as a StateFlow and read with collectAsStateWithLifecycle(), which stops collecting while the screen isn't visible, unlike plain collectAsState().

val uiState by viewModel.uiState.collectAsStateWithLifecycle()

The architectural pattern that ties this together is state hoisting, following unidirectional data flow. A composable that owns state internally is stateful, one that receives state as a parameter and reports changes through a callback is stateless. Preferring stateless, hoisted composables and pushing the actual state ownership up to a ViewModel is what keeps state management predictable as a screen grows more complex.

What is MutableState and how does recomposition happen?

Tier: CommonDifficulty: Medium

MutableState<T> is Compose's observable holder for a single value. It's the mutable version of State<T>, it has a settable value property, and Compose is watching that property, so writing to it is what actually triggers recomposition.

var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("Count: $count") } // reading count subscribes this Text to it

mutableStateOf(0) creates the MutableState instance. remember is what makes that instance survive recomposition instead of being rebuilt from scratch every time the function reruns. The by delegate is just syntax sugar, it lets you read and write count directly instead of writing count.value everywhere.

Recomposition happens through Compose's snapshot system underneath all of this. Every composable that reads count.value during composition gets registered as a subscriber to that specific state object. When count++ runs, it writes a new value into the snapshot, which schedules a recomposition, and only the composables that actually read count get rerun. A sibling that never read count is left alone.

That's also why a plain var count = 0 without mutableStateOf does nothing visible, Compose has no way to know that value changed, because nothing subscribed to it and nothing notified the Composition. State only feeds back into the UI when it's held in something Compose can observe, and MutableState is the basic building block for that.

What is state hoisting?

Tier: CommonDifficulty: Medium

State hoisting is moving a composable's state up to its caller, so the composable itself becomes stateless, it just takes a value and an event callback as parameters instead of owning anything.

// before hoisting, owns its own state
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) { Text("Count: $count") }
}

// after hoisting, caller owns the state
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Button(onClick = onIncrement) { Text("Count: $count") }
}

The pattern is always the same shape, a value: T to display and an onValueChange style lambda to request a change. The composable displays the value and reports events, it never mutates state directly, which is exactly the unidirectional data flow Compose is built around, state down, events up.

Hoisting pays off in a few concrete ways. The composable becomes reusable, since it doesn't care where the state actually lives. It becomes easier to test and preview, since you can pass in any value you want without setting up the composable's internal state first. And it gives you a single source of truth, usually a ViewModel, instead of the same piece of data living in two places that can drift out of sync.

Not everything needs hoisting though. State that's purely local and nothing outside the composable ever needs to read or change, like whether a dropdown is currently expanded, is fine to keep internal. The rule of thumb is to hoist to at least the lowest common parent of everything that needs to read the state, and no further than that.

What is Strong Skipping Mode in Compose?

Tier: CommonDifficulty: Medium

Strong skipping mode makes composables with unstable parameters skippable too, instead of forcing them to recompose every time just because one parameter's type couldn't be proven stable. It's a Compose compiler feature, on by default since the Kotlin 2.0 Compose compiler, and available as an opt in flag on earlier versions.

Before strong skipping, a single unstable parameter, like a plain List<Item> or an interface without @Stable, disqualified the whole composable from skipping. Every recomposition of the parent recomposed that composable too, even if the actual data hadn't changed, because Compose couldn't safely tell whether it had.

// unstable List parameter, unskippable without strong skipping mode
@Composable
fun ItemList(items: List<Item>) {
    Column { items.forEach { ItemRow(it) } }
}

Strong skipping mode changes the comparison strategy for unstable parameters from a structural stability guarantee to instance equality, so the composable now skips when it's called again with the exact same instance, and only recomposes on a genuinely new one. It also automatically remembers lambdas defined inside a composable, so a lambda that captures no changing values doesn't count as a new instance on every recomposition, closing off a common source of accidental recomposition that used to require wrapping things in remember by hand.

It's a real win for apps with a lot of legacy code using plain List or interfaces that were never annotated, but it's a safety net, not a substitute for correct stability. Genuinely immutable, correctly annotated types still skip more precisely and avoid the instance equality checks strong skipping adds, so it's worth fixing types where you reasonably can rather than leaning on this for everything.

Does recomposition of one child composable affect a sibling composable? If so, how?

Tier: CommonDifficulty: Hard

No, not by default. Compose recomposes at the granularity of the composable that actually reads the changed state, so a sibling that doesn't read that state is skipped entirely, it's completely unaffected by its neighbor recomposing.

@Composable
fun Parent() {
    var count by remember { mutableStateOf(0) }
    Column {
        Counter(count) { count++ } // reads count, recomposes when it changes
        StaticHeader() // doesn't read count, never recomposes because of it
    }
}

When count changes, Compose looks at exactly which composables read count.value during the last composition. Counter did, so it reruns. StaticHeader didn't, so it's skipped, regardless of the fact that they're siblings under the same parent. Compose doesn't recompose "the parent's children," it recomposes the specific functions and lambdas that subscribed to that piece of state.

Where siblings do end up affecting each other is when a parameter they share turns out to be unstable. If Parent recomposes and passes a plain, unstable List down to both children, Compose can't prove that list didn't change, so both children may be forced to recompose along with it, even though neither one triggered the change itself. That's the practical reason stability matters so much in Compose, an unstable type at a shared boundary can drag unrelated composables into recomposing together.

The other case is a shared remember block. If two composables both read from the same remember { derivedStateOf { ... } } , they're really both subscribers to that one computed value, so of course they both recompose when it changes, but that's expected, they're depending on the same state on purpose.

Explain derivedStateOf.

Tier: CommonDifficulty: Hard

derivedStateOf computes a value from other state, but only notifies the Composition when the computed result actually changes, even if the inputs it read change far more often. Use it when the input updates a lot faster than the UI actually needs to react.

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0 // recomputed on every scroll, but only changes rarely
    }
}

listState.firstVisibleItemIndex changes on every pixel of scroll, but showButton only flips twice in a typical scroll session, once when the user scrolls past the first item, once when they scroll back to the top. Without derivedStateOf, reading firstVisibleItemIndex directly in a composable would recompose that composable on every scroll frame. With it, the composable only recomposes on those two actual flips.

It's easy to reach for this wherever multiple state reads get combined, but that's usually the wrong call. If two states change together and the UI needs to reflect both changes anyway, derivedStateOf just adds overhead, since it recomputes a lambda on every read and does an equality check to decide whether to notify, for no real benefit over just reading both values directly.

// unnecessary, fullName changes exactly as often as firstName or lastName does
val fullName by remember { derivedStateOf { "$firstName $lastName" } }

The test is whether the derived value changes less often than its inputs. If yes, derivedStateOf saves real recomposition work. If the derived value tracks its inputs one for one, skip it and read the state directly.

Explain rememberUpdatedState.

Tier: CommonDifficulty: Hard

rememberUpdatedState lets a long running effect always see the latest value of something, without that value's changes causing the effect itself to restart. It exists for the specific case where an effect's key shouldn't include a value, but the effect still needs to read that value's current version.

@Composable
fun SplashScreen(onTimeout: () -> Unit) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)
    LaunchedEffect(Unit) {
        delay(3000)
        currentOnTimeout() // always calls the latest lambda, even if onTimeout changed mid delay
    }
}

Without it, LaunchedEffect(Unit) { delay(3000); onTimeout() } would capture whatever onTimeout lambda existed when the effect launched. If the parent recomposes and passes a new onTimeout before the delay finishes, that new lambda is ignored, the effect is still holding the stale one from three seconds ago. You could fix that by keying the effect on onTimeout, but then every recomposition that changes the lambda restarts the whole three second delay, which defeats the point of a splash timer.

rememberUpdatedState splits the two concerns apart. currentOnTimeout is a State that always reflects the newest value of onTimeout, updated on every recomposition, but reading .value inside the coroutine doesn't restart anything, since the coroutine isn't watching it as a key. So the delay runs once, uninterrupted, and whichever onTimeout is current when it finishes is the one that gets called.

The pattern generalizes to anything a long lived effect needs to reference without wanting it in the key list, a callback, a piece of config, anything that changes for reasons unrelated to when the effect should restart.

What are stable types that can skip recomposition?

Tier: CommonDifficulty: Hard

A type is stable if Compose can trust that equals() will keep returning the same result for the same values, and that if a public property changes, the Composition gets notified so it can recompose. Stability is what lets Compose safely skip recomposing a composable when its parameters haven't actually changed.

A few categories are stable by default.

  • All primitive types, Boolean, Int, Long, Float, Char, and so on.
  • String.
  • All function types, meaning lambdas.
  • MutableState<T> and the other Compose state holders. They're mutable, but stable, because reading them inside a composable subscribes to their changes, so Compose always knows when they update.
  • A data class where every property is one of the above, or another stable type, inferred automatically by the compose compiler plugin.

Things that are not stable by default include a plain List, Set, or Map, because nothing stops the caller from mutating the same instance in place without Compose ever finding out, and any interface or class with a public var that Compose can't prove will trigger a notification.

@Stable
interface UiState {
    val isLoading: Boolean
}

When you know a type actually behaves correctly, meaning its equality is meaningful and it notifies on change, you can tell Compose that yourself with @Stable, or @Immutable if the type genuinely never changes after construction. That's mainly useful for interfaces and classes the compiler can't infer stability for on its own, since it can't see implementations that don't exist yet. Getting this right matters because one unstable parameter on a composable makes that composable unskippable, and unskippable composables recompose every time their parent does, whether they need to or not.

Side Effects

The real question under these is which effect API you would reach for, and why that one.

What are side effects in Jetpack Compose?

Tier: EssentialDifficulty: Medium

A side effect is a change to app state that happens outside a composable's own scope, like starting a network call, writing to a database, showing a snackbar, or navigating to another screen. Composable functions are supposed to be side effect free, since Compose might call them multiple times, skip them, or run them out of order, so Compose gives you a separate set of Effect APIs to run this kind of work in a controlled, lifecycle aware way.

The main ones cover different shapes of work.

  • LaunchedEffect runs a suspend block tied to the composable's lifetime, canceled automatically when it leaves the Composition.
  • DisposableEffect is for effects that need explicit cleanup, it requires an onDispose block, useful for registering and unregistering listeners.
  • SideEffect runs after every successful recomposition, for publishing Compose state out to non-Compose code, like an analytics library.
  • rememberCoroutineScope gives you a CoroutineScope you can launch from inside a callback, like a button click, rather than from the composable body directly.
  • produceState converts an external source like a Flow or a callback based API into Compose State.
  • derivedStateOf recomputes a value from other state, but only notifies the Composition when the computed result actually changes.
LaunchedEffect(userId) {
    val user = repository.fetchUser(userId) // suspend call, safe here
    onUserLoaded(user)
}

The thing all of these share is a key parameter, whatever value you pass in determines when Compose cancels the old effect and starts a new one. Get the key wrong, like leaving it out or passing something that changes too often, and you either miss updates or restart expensive work needlessly. Picking the right Effect API for the job, rather than reaching for LaunchedEffect for everything, is most of what makes side effects in Compose predictable.

What is the difference between LaunchedEffect and DisposableEffect?

Tier: EssentialDifficulty: Medium

LaunchedEffect runs a suspend function, DisposableEffect runs a regular block that must register something and explicitly clean it up. Reach for LaunchedEffect when the work is a coroutine, reach for DisposableEffect when the work is a subscription or a listener that has to be torn down.

// LaunchedEffect, suspend work, cancels automatically on key change or leaving composition
LaunchedEffect(userId) {
    val user = repository.fetchUser(userId)
    onUserLoaded(user)
}

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

LaunchedEffect launches a coroutine scoped to the composable, when the key changes or the composable leaves the Composition, that coroutine is canceled, and Compose starts a fresh one if the composable is still around. Cleanup is implicit, cancellation is the cleanup.

DisposableEffect doesn't run suspend code at all, it's for setup that has a distinct undo step, registering a BroadcastReceiver, adding a LifecycleEventObserver, subscribing to a sensor. Because that undo step isn't automatic the way coroutine cancellation is, DisposableEffect forces you to write an onDispose block yourself, and the compiler won't let you skip it. Miss that block with LaunchedEffect and there's nothing to miss, cancellation handles it, miss it with DisposableEffect and you'd leak the listener, so the API makes it mandatory.

The rule of thumb is simple. If what you're doing is naturally a coroutine, a network call, a delay, collecting a flow, use LaunchedEffect. If what you're doing is register something now, unregister it later, use DisposableEffect.

How do you launch a coroutine from a composable function?

Tier: CommonDifficulty: Medium

You launch it from inside LaunchedEffect, not directly in the composable body, since a composable function itself can't call suspend functions, only the Effect APIs give you a coroutine scope tied to the Composition.

@Composable
fun UserProfile(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }

    LaunchedEffect(userId) {
        user = repository.fetchUser(userId) // suspend call, runs here
    }

    user?.let { ProfileContent(it) }
}

LaunchedEffect launches its coroutine the first time the composable enters the Composition, and cancels and relaunches it if the key, userId here, changes on a later recomposition. Leave the composable's Composition entirely and the coroutine is canceled outright. That lifecycle is exactly why you can't just call launch { } from the composable body directly, there's no scope there tied to when the composable is actually alive.

The key matters more than it looks. LaunchedEffect(Unit) runs the coroutine exactly once for the composable's whole time in the Composition, useful for a one shot load. LaunchedEffect(userId) restarts the fetch whenever userId changes, which is what you want when the composable gets reused for a different user without leaving the Composition.

If the coroutine needs to start from a callback instead, like a button's onClick, LaunchedEffect doesn't fit, since it only runs when the composable itself enters or its key changes, not on a user action. That's the case rememberCoroutineScope is for instead.

What is rememberCoroutineScope and what are its use cases? How do you launch a coroutine from a non-composable function tied to the composition?

Tier: CommonDifficulty: Medium

rememberCoroutineScope gives you a CoroutineScope that's tied to the composable's lifetime, but that you launch from manually, typically inside an event handler like a button click, rather than automatically the way LaunchedEffect does.

@Composable
fun SnackbarDemo(snackbarHostState: SnackbarHostState) {
    val scope = rememberCoroutineScope()
    Button(onClick = {
        scope.launch {
            snackbarHostState.showSnackbar("Something happened") // suspend call from a click
        }
    }) {
        Text("Show snackbar")
    }
}

The scope itself is remembered across recompositions, and Compose cancels it automatically when the composable leaves the Composition, so you get the same cleanup guarantee LaunchedEffect gives you, without the coroutine having to start immediately on composition. That's the whole reason it exists, onClick is a plain lambda, not a suspend function and not a composable, so there's nowhere inside it to call a suspend function directly. rememberCoroutineScope hands you a scope you can call .launch { } on from that non-composable context, and because it's the same scope tied to the Composition, launching from it is what "tied to the composition" means here, not tied to any one specific call.

Typical use cases beyond snackbars are anything that should start from a user action rather than automatically, showing a bottom sheet, animating a scroll position with LazyListState.animateScrollToItem(), or triggering a one off save on a button tap.

The distinction to keep straight against LaunchedEffect is when the coroutine starts. LaunchedEffect starts automatically when the composable enters the Composition or its key changes. rememberCoroutineScope starts only when you call .launch yourself, which is what makes it the right tool for coroutines triggered by user interaction instead of by composition.

What is the difference between remember and LaunchedEffect?

Tier: CommonDifficulty: Medium

remember caches a value across recompositions. LaunchedEffect runs a suspend coroutine tied to the composable's lifetime. They solve different problems, and they're often used together rather than as alternatives.

val listState = remember { LazyListState() } // caches an object

LaunchedEffect(userId) { // runs a coroutine
    val user = repository.fetchUser(userId)
    onUserLoaded(user)
}

remember is not an effect at all, it's a memoization tool. It stores whatever the lambda returns in the Composition's slot table, and hands back the same instance on the next recomposition instead of recreating it. It runs synchronously, during composition, and it can't call suspend functions.

LaunchedEffect is an effect, it exists specifically to run suspend code, a network call, a delay, collecting a Flow, from a place that's safe to do so, since a composable body itself can never call suspend functions directly. It launches a coroutine when the composable enters the Composition, cancels and relaunches it if its key changes, and cancels it for good when the composable leaves.

A common pattern combines both, remember { mutableStateOf(...) } to hold a piece of state, and LaunchedEffect to populate it asynchronously.

var user by remember { mutableStateOf<User?>(null) }
LaunchedEffect(userId) {
    user = repository.fetchUser(userId) // fills the remembered state
}

If you only need to hold onto an object, use remember. If you need to do asynchronous work, use LaunchedEffect. Needing both at once is normal, not a sign you're using the wrong one.

Layout, Modifiers & Custom UI

How can you handle user input and events in Jetpack Compose?

Tier: CommonDifficulty: Easy

User input in Compose is handled through lambda callbacks passed as parameters, not listener objects attached after the fact. A Button takes an onClick lambda, a TextField takes an onValueChange lambda, and you update state from inside them, which triggers recomposition to reflect the change.

var text by remember { mutableStateOf("") }

TextField(value = text, onValueChange = { text = it })
Button(onClick = { submit(text) }) { Text("Submit") }

That's the standard pattern for most components, Material widgets already expose the right callback for their obvious interaction, click for Button, value change for TextField, checked change for Checkbox. For anything at a lower level than a specific component, gestures live on the Modifier.

Box(
    modifier = Modifier
        .clickable { onTap() } // simple tap
        .pointerInput(Unit) {
            detectDragGestures { change, dragAmount -> /* drag handling */ }
        }
)

Modifier.clickable covers a plain tap with ripple and accessibility semantics built in. For drag, swipe, or multi touch gestures, Modifier.pointerInput gives you the raw pointer event stream to build a custom gesture detector against, using functions like detectDragGestures or detectTapGestures from the gesture APIs.

Following unidirectional data flow here matters just as much as it does for state generally, the composable receiving the input reports what happened through the callback, and whatever owns the state, often a ViewModel, decides what to actually do with it. The composable itself stays a thin layer that just wires the interaction to an event.

What is the difference between LazyColumn and RecyclerView?

Tier: CommonDifficulty: Easy

LazyColumn is Compose's scrolling list, and it does the same core job as RecyclerView, only rendering the items currently visible on screen, but it gets there without any of RecyclerView's ceremony.

LazyColumn {
    items(movies, key = { it.id }) { movie ->
        MovieRow(movie)
    }
}

RecyclerView needs an Adapter, a ViewHolder, and layout XML for each item, plus manual view recycling logic, onCreateViewHolder and onBindViewHolder, that you're responsible for getting right. LazyColumn needs none of that, items() takes your data directly, and you describe each row as a composable, Compose handles what's on and off screen internally.

The recycling model itself is different too. RecyclerView physically reuses View instances, a scrolled off row's View object gets its content swapped and reused for the next row scrolling in. LazyColumn doesn't reuse the composable in that same sense, it reuses composition slots, Compose's own internal caching of what a composable produced last time, which is a lighter weight mechanism suited to Compose's declarative model rather than a direct analog of View recycling.

The key parameter in items() matters here for the same reason it matters in the wider composable lifecycle question, without a stable key, Compose falls back to matching items by position, so inserting or reordering items can make it lose track of which composable belongs to which piece of data, restarting side effects and losing item level state like scroll offsets or expanded rows. Passing a stable ID as the key fixes that, the same way it does for any composable rendered in a loop.

Feature for feature, LazyColumn covers what RecyclerView did, sticky headers, item animations, and multiple view types, called item content types in Compose, are all supported, just expressed as composables and lambdas instead of XML and adapter code.

What is the role of the Modifier in Jetpack Compose?

Tier: CommonDifficulty: Easy

A Modifier decorates a composable, controlling its size, layout, appearance, and behavior, without the composable itself needing a parameter for every possible tweak. Instead of a Text composable exposing padding, clickable, background, and a dozen other parameters directly, it just takes one modifier: Modifier parameter, and you chain whatever behavior you need onto it.

Text(
    text = "Hello",
    modifier = Modifier
        .padding(16.dp)
        .clickable { onClick() }
        .background(Color.LightGray)
)

Each function in the chain wraps the Modifier returned by the one before it, and order genuinely changes the result, since Compose applies them in sequence. Modifier.clickable().padding() makes the whole area, including the padding, clickable, Modifier.padding().clickable() only makes the inner content clickable, with the padding sitting outside the clickable region. That's an explicit tradeoff Compose makes on purpose, the View system's box model had padding and margin behave a fixed way, Compose instead gives you the raw ordering and lets you decide.

Some modifiers are scoped, weight only compiles inside RowScope or ColumnScope, matchParentSize only inside BoxScope, which stops you from writing a modifier that wouldn't make sense outside the container it depends on.

Well behaved custom composables always accept a modifier: Modifier = Modifier parameter and pass it to their outermost child, which is what lets a caller apply padding, size, or click handling to your composable from outside, the same way they can to any built in one. Skipping that parameter makes a composable much harder to reuse in a layout you don't fully control.

What is CompositionLocal?

Tier: CommonDifficulty: Medium

CompositionLocal is a way to pass a value implicitly down the composable tree, so any descendant can read it without it being threaded through every intermediate composable's parameters. Compose's own theming, MaterialTheme.colorScheme and MaterialTheme.typography, is built on top of it.

val LocalElevations = compositionLocalOf { Elevations() } // needs a sensible default

CompositionLocalProvider(LocalElevations provides Elevations(card = 2.dp)) {
    MyScreen() // any descendant can read LocalElevations.current
}

// deep inside MyScreen, no parameter needed to get here
val elevation = LocalElevations.current.card

There are two flavors, and the difference is about how invalidation works, not what problem they solve. compositionLocalOf only recomposes the specific composables that actually read .current, which fits values that might change, like a theme based elevation. staticCompositionLocalOf skips that fine grained tracking entirely and just recomposes the whole content lambda under the provider whenever the value changes, which is faster to read but expensive to change, so it's meant for values that are set once and effectively never change again, like a LocalContext.

It's easy to reach for CompositionLocal as a shortcut around passing a parameter, but that's usually the wrong call. It creates an implicit dependency, a composable that reads LocalElevations.current doesn't advertise that dependency anywhere in its signature, which makes the code harder to follow and harder to test in isolation. It's a genuinely good fit for cross cutting, tree scoped concerns like theming, locale, or layout direction, where every composable plausibly needs the value and passing it explicitly everywhere would be pure ceremony. For a value only a few composables actually need, an explicit parameter is almost always the better choice.

Explain the Jetpack Compose phases.

Tier: CommonDifficulty: Hard

Compose turns state into pixels in three phases, running in this order every frame, composition, layout, then drawing.

  • Composition. Your composable functions run and build up a tree describing what the UI should be, which composables exist and what parameters they were called with. This is where recomposition happens too, rerunning the functions that read changed state.
  • Layout. Each node in that tree is measured and placed. A parent asks its children how big they want to be, given the constraints it passes down, then the parent decides where to position each child. This is a single pass, measure then place, not the multi pass measurement the View system sometimes needed.
  • Drawing. The tree, now measured and positioned, is actually rendered to the screen, filling in colors, text, shapes, and images.

Each phase depends only on the phase before it, composition produces the tree layout needs, layout produces the positions drawing needs, and that's what lets Compose skip a phase entirely when it can. If a state change only affects a color, Compose can redraw without re-measuring anything. If it only affects size or position, Compose can re-layout without recomposing untouched composables.

Modifier.offset { IntOffset(scrollState.value, 0) } // reads scrollState during layout, not composition

That's also the reasoning behind preferring the lambda based overloads of modifiers like offset and graphicsLayer over reading state directly in the composable body. Reading scrollState.value inside a plain Modifier.offset(scrollState.value.dp) call forces a read during composition, so every scroll pixel triggers recomposition. Deferring the read into the lambda pushes it into the layout or drawing phase instead, where it belongs, since scrolling only actually needs to move things, not rebuild the composable tree.

How do you create custom layouts / custom views in Compose?

Tier: CommonDifficulty: Hard

For a custom layout, you use the Layout composable and control measurement and placement yourself, instead of composing existing containers like Row or Column.

@Composable
fun CustomLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        val placeables = measurables.map { it.measure(constraints) } // measure each child
        layout(constraints.maxWidth, constraints.maxHeight) {
            var y = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = y) // then place each child
                y += placeable.height
            }
        }
    }
}

Layout gives you the list of measurables, the unmeasured children, and the incoming constraints from the parent. You call measure() on each child yourself to turn it into a Placeable, decide your own layout's size, then place each child at whatever coordinates your algorithm computes. Row, Column, and Box are all just Layout under the hood, this is the same primitive Compose's own containers are built from, not a workaround.

For simpler custom UI that doesn't need to arrange children, Modifier.layout { measurable, constraints -> ... } gives you the same measure and place control but scoped to a single composable, useful for something like a custom padding or alignment behavior without writing a whole new container.

"Custom views" in the Compose sense usually means a composable that draws its own visuals rather than composing other composables, which is what Canvas is for, giving you a DrawScope to draw shapes, paths, and text directly.

Canvas(modifier = Modifier.size(100.dp)) {
    drawCircle(color = Color.Red, radius = size.minDimension / 2)
}

Between Layout for custom arrangement and Canvas for custom drawing, most things that would have needed a custom View subclass in the old system are achievable without ever leaving Compose.

Navigation & Interop

Can you use both Jetpack Compose and Android Views in a single app?

Tier: CommonDifficulty: Medium

Yes, Compose and the View system interoperate directly, and that's how most real apps migrate, one screen at a time, rather than rewriting everything at once.

// legacy View inside a Compose screen
@Composable
fun MapScreen() {
    AndroidView(factory = { context -> MapView(context) })
}
// Compose inside a legacy View based screen
class ProfileFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) =
        ComposeView(requireContext()).apply {
            setContent { ProfileScreen() }
        }
}

AndroidView embeds a legacy View, like a MapView from a third party SDK that has no Compose equivalent yet, inside a Compose screen. ComposeView does the opposite, it embeds Compose content inside a Fragment or an Activity that's still XML and View based.

This interop is what makes incremental adoption realistic. A team doesn't have to freeze feature work to rewrite an entire app in Compose, they can convert one screen, ship it, and move to the next, while screens on both sides of that boundary keep working together in the same app, sharing the same navigation graph and the same ViewModels.

The tradeoff is that state doesn't cross that boundary automatically, an AndroidView's internal state and a Compose composable's state are managed separately, so keeping them in sync, like a legacy View reacting to a state change from a ViewModel, has to be wired up explicitly through the update callback on AndroidView.

How do you handle navigation in Jetpack Compose?

Tier: CommonDifficulty: Medium

Navigation Compose handles it, built on the same NavController and back stack model as the older Fragment based Navigation library, just with composable destinations instead of fragment destinations.

val navController = rememberNavController()

NavHost(navController = navController, startDestination = "home") {
    composable("home") { HomeScreen(onOpenDetail = { id -> navController.navigate("detail/$id") }) }
    composable("detail/{itemId}") { backStackEntry ->
        val itemId = backStackEntry.arguments?.getString("itemId")
        DetailScreen(itemId)
    }
}

rememberNavController() creates the controller and keeps it across recomposition. NavHost declares the destinations and which composable renders for each route. navController.navigate("detail/$id") pushes a new destination onto the back stack, and popBackStack() or the system back button pops it, the same mental model as Fragment navigation, just expressed as composables and string routes instead of a nav_graph.xml.

Passing data between destinations goes through the route itself, as a path or query argument, rather than a Bundle, since each destination is a plain composable function, not an object with its own saved state to stuff arguments into. For anything too large or complex to put in a route string, like a full object, the usual approach is a shared ViewModel scoped to the navigation graph, or scoped to a parent destination that both screens can reach.

Deep links, nested graphs, and passing type safe arguments instead of raw strings all work too, using navArgument for typed arguments, and the newer type safe navigation APIs let you define routes as serializable classes instead of hand built string templates, which cuts out a common source of typo bugs in the route strings themselves.

What is AndroidView in Compose?

Tier: CommonDifficulty: Medium

AndroidView is the composable that embeds a legacy Android View inside Compose, for cases where no Compose equivalent exists yet, like a third party SDK's map or ad view.

@Composable
fun MapScreen(cameraPosition: LatLng) {
    AndroidView(
        factory = { context -> MapView(context).apply { onCreate(null) } }, // built once
        update = { mapView -> mapView.moveCamera(cameraPosition) } // called on every recomposition
    )
}

factory runs exactly once, the first time this AndroidView enters the Composition, and is where you construct the actual View instance. update runs on that first composition and again on every recomposition after, so it's where you push Compose state into the View, since the View has no way to observe Compose's state on its own. That's the important asymmetry to remember, AndroidView doesn't make the View reactive by magic, update is what wires state changes through by hand.

Compose still measures and positions the AndroidView as one node in its layout tree, so modifiers like Modifier.fillMaxWidth() or Modifier.padding() work on it the same as any other composable, but everything inside the View itself is opaque to Compose, it's managed the traditional way once you're past that boundary.

AndroidView is meant as an interop bridge for the incremental migration case, not a long term escape hatch. Anything that has a real Compose equivalent, a Button, a RecyclerView replaced by LazyColumn, is better rewritten in Compose, since a wrapped View doesn't get Compose's automatic recomposition, skipping, or the rest of the performance work the runtime does for native composables.

Compose Performance

These arrive once an interviewer believes you have shipped Compose rather than tried it.

What are the best practices for performance optimization in Jetpack Compose?

Tier: CommonDifficulty: Medium

Compose performance work mostly comes down to one goal, letting Compose skip recomposing composables that don't actually need to update. A handful of practices cover most of what shows up in real apps.

  • Keep parameters stable. An unstable type, like a plain List or an un-annotated interface, disables skipping for that composable. Prefer immutable collections or mark custom types @Stable or @Immutable when Compose can't infer it.
  • Hoist state no further than it needs to go. State held too high forces everything below it to recompose together, hoisting it only as far as the lowest common reader keeps recomposition scoped tighter.
  • Use derivedStateOf for values that change less often than their inputs. Reading listState.firstVisibleItemIndex directly recomposes on every scroll pixel, deriving a boolean from it recomposes only when that boolean actually flips.
  • Give lazy list items a key. LazyColumn and LazyRow reorder and reuse composables correctly, and keep their side effects intact, only when each item has a stable key, usually an ID.
  • Defer reads where possible. Passing a lambda like { someState } instead of the value itself into something like Modifier.offset lets that read happen during layout instead of forcing composition to rerun.
  • Avoid heavy work directly in a composable body. Expensive calculations belong in remember with the right keys, or off the UI thread entirely in a ViewModel or a LaunchedEffect.
LazyColumn {
    items(movies, key = { it.id }) { movie -> MovieRow(movie) } // stable identity, cheap reordering
}

Beyond individual composables, the Compose compiler can generate a metrics report showing which composables are skippable, restartable, and stable, which is the most direct way to see where an app is actually losing recomposition efficiency instead of guessing. Enabling strong skipping mode, on by default from the Kotlin 2.0 Compose compiler onward, also closes a lot of gaps automatically for code that hasn't been fully annotated for stability yet, though it's a safety net rather than a substitute for fixing the underlying types.

Less common, worth knowing

These come up less often. Skim them once you are comfortable with everything above.

State & Recomposition

How can you convert a non-Compose state into a Compose state?

Tier: Less commonDifficulty: Medium

You convert it with one of Compose's extension functions that wraps the external source in a State<T>, since Compose only auto recomposes when reading something that implements State, not an arbitrary observable type.

val uiState by viewModel.uiStateFlow.collectAsStateWithLifecycle() // Flow to State
val legacyValue by legacyLiveData.observeAsState() // LiveData to State

Which function you reach for depends on the source.

  • A StateFlow or Flow becomes State with collectAsStateWithLifecycle(), the version to reach for in an app, since it stops collecting while the lifecycle isn't at least STARTED and restarts when it is, instead of running the collection the whole time the composable is in the Composition the way plain collectAsState() does. Prefer it over collectAsState() unless you're in a module that can't take the lifecycle-runtime-compose dependency.
  • LiveData becomes State with observeAsState().
  • RxJava Observable or Flowable becomes State with subscribeAsState().
  • A callback based API with no Flow or LiveData wrapper at all is the case produceState is for, it launches a coroutine when the composable enters the Composition, lets you set value from a callback, and cleans up in awaitDispose when it leaves.
@Composable
fun loadImage(url: String): State<Result<Image>> = produceState(initialValue = Result.Loading, url) {
    value = imageRepository.load(url) // set value from a suspend or callback based call
}

Whichever route you take, the important part is doing this conversion once, at the composable boundary, and letting everything downstream just read plain Compose State. Passing the raw Flow or LiveData itself deeper into the composable tree defeats the point, since nothing there would know to observe it.

Side Effects

How can you handle asynchronous operations in Jetpack Compose?

Tier: Less commonDifficulty: Medium

You handle asynchronous work through Compose's Effect APIs, not by calling suspend functions or launching coroutines directly from a composable body, since composables have to stay fast and side effect free.

  • LaunchedEffect runs a suspend block automatically when the composable enters the Composition, or restarts it when its key changes. This is the default choice for a one shot fetch or a load tied to some input.
  • rememberCoroutineScope gives you a scope to launch from manually, for async work started by a user action like a button click, where nothing suspend can be called directly inside the click lambda.
  • produceState converts a callback based or push style async source into Compose State, so the rest of the UI can just read a value instead of dealing with the async mechanics itself.
  • collectAsStateWithLifecycle collects a StateFlow from a ViewModel into State, pausing collection while the screen isn't visible.
@Composable
fun UserProfile(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }
    LaunchedEffect(userId) {
        user = repository.fetchUser(userId) // suspend call, safe here
    }
    user?.let { ProfileContent(it) }
}

The pattern across all of them is the same, the async work happens inside an Effect API tied to the composable's lifecycle, and the result lands in a State that the composable simply reads. Whichever API you use, Compose cancels the underlying coroutine automatically when the composable leaves the Composition, so you don't have to manage that cancellation by hand the way you would outside Compose.

Most real screens actually push the async work up into a ViewModel instead of running it directly in a composable, exposing a StateFlow<UiState> that the composable collects with collectAsStateWithLifecycle(). That keeps the async logic testable independent of Compose and keeps the composable itself limited to just displaying whatever state it's handed.

Layout, Modifiers & Custom UI

How do you use Canvas in Compose?

Tier: Less commonDifficulty: Medium

Canvas is the composable for drawing directly, shapes, paths, text, images, rather than composing other composables. It hands you a DrawScope where you call drawing functions against explicit coordinates and sizes.

Canvas(modifier = Modifier.size(200.dp)) {
    drawCircle(color = Color.Blue, radius = size.minDimension / 2, center = center)
    drawLine(color = Color.Red, start = Offset(0f, 0f), end = Offset(size.width, size.height), strokeWidth = 4f)
    drawRect(color = Color.Green, topLeft = Offset(10f, 10f), size = Size(50f, 50f))
}

Inside the lambda, size gives you the Canvas's measured dimensions, and center is a shortcut for its midpoint, both useful for drawing something proportional to the available space rather than hardcoding pixel values. DrawScope also exposes lower level primitives like drawPath for arbitrary shapes and drawImage for bitmaps, and it works in a coordinate system with the origin at the top left, same as the View system's Canvas always did.

Canvas sits underneath things like custom progress indicators, charts, or a signature pad, anywhere the built in Material components don't already cover the visual you need. It's built on Modifier.drawBehind under the hood, which is the more general modifier for drawing that doesn't require wrapping content in its own composable, useful when you want to draw behind or on top of a composable that already has its own content.

Box(modifier = Modifier.drawBehind {
    drawRect(color = Color.LightGray) // draws behind whatever Box contains
})

Since Canvas draws are recomposed like anything else that reads state, an animated draw, like a progress ring that changes over time, is just a matter of reading an Animatable or animated state value inside the draw lambda, and Compose redraws it as that value updates.

What are Semantics in Jetpack Compose?

Tier: Less commonDifficulty: Medium

Semantics are metadata attached to composables describing what they mean, not how they look, so tools like screen readers and UI tests can understand and interact with a screen without parsing pixels. Compose builds this into a separate semantics tree that runs alongside the normal composition tree.

Icon(
    imageVector = Icons.Default.CameraAlt,
    contentDescription = "Take a photo" // semantic meaning, not a caption
)

The composition tree describes how to draw the screen. The semantics tree describes what's on it in terms accessibility services and tests can act on, things like a text value, a role such as button or checkbox, whether an element is currently selected, or a custom action available on it. Material and Foundation components come with reasonable semantics already built in, Button reports itself as a button, Text reports its text content, a custom composable built from lower level pieces like Layout or Canvas has none of that by default and needs it added explicitly through Modifier.semantics { }.

Modifier.semantics {
    contentDescription = "3 unread messages"
    role = Role.Button
}

TalkBack and other accessibility services read the unmerged semantics tree, where each node keeps its own properties, since their own merging logic decides how to group things for the user. Compose's testing framework instead reads a merged tree by default, where a parent's semantics fold in its descendants, so a test can find and interact with a whole component, like a whole list item, as one semantic unit rather than reaching into its individual child composables.

Getting semantics right isn't just an accessibility nice to have, it's also what makes a composable testable with composeTestRule.onNode { } matchers at all, since those matchers query the semantics tree, not the visual layout.

What are your thoughts on flat hierarchy and ConstraintLayout in Compose vs. the older XML view hierarchy?

Tier: Less commonDifficulty: Medium

Compose's ConstraintLayout exists for the same reason the XML one did, keeping a deeply nested UI flat, but it matters a lot less in Compose than it did in the View system, because Compose's layout pass is already cheap enough that nesting isn't the performance problem it used to be.

ConstraintLayout {
    val (image, title) = createRefs()
    Image(painter, modifier = Modifier.constrainAs(image) { top.linkTo(parent.top) })
    Text("Title", modifier = Modifier.constrainAs(title) { top.linkTo(image.bottom) })
}

In the XML View system, a deep tree of nested LinearLayouts meant a deep tree of measure passes, and some of those layouts required multiple passes to resolve, so ConstraintLayout earned its place as the standard fix, flattening a complex UI into one layout with constraints instead of several layers of nesting.

Compose's layout model is different at the root. Most Compose layouts, Row, Column, Box, measure children in a single pass, and nesting a few of them doesn't carry anywhere near the same cost a deeply nested View hierarchy did. So in Compose, Row { Column { Box { ... } } } a few levels deep is usually just fine, readable, and not a real performance concern, where the equivalent XML nesting would have been a flag in a performance review.

That changes the calculus for when ConstraintLayout earns its place. It's still the right tool for layouts with genuinely complex relative positioning, elements that need to align to each other in ways that don't reduce cleanly to nested rows and columns, guideline based responsive layouts, or barrier and chain based arrangements. But reaching for it just to avoid nesting, the way people sometimes did in XML, isn't necessary in Compose. A few nested Columns and Rows is idiomatic Compose, not a smell.

Navigation & Interop

How do you handle orientation changes in Jetpack Compose?

Tier: Less commonDifficulty: Medium

Compose handles orientation the same way it handles any layout change, by recomposing with new constraints, you don't write orientation specific callbacks the way onConfigurationChanged sometimes required in the View system. The part that actually needs your attention is making sure state survives the recreation that a rotation still triggers by default.

val configuration = LocalConfiguration.current
if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
    LandscapeLayout()
} else {
    PortraitLayout()
}

LocalConfiguration.current gives you the current orientation, screen size, and other configuration values directly in a composable, and reading it makes that composable recompose automatically when the configuration changes, no manual listener needed. For layouts that should adapt more generally than a simple landscape or portrait branch, WindowSizeClass is the recommended approach, it buckets the available width and height into compact, medium, and expanded, which holds up better across phones, foldables, and tablets than checking orientation directly.

Rotation still destroys and recreates the Activity by default, the same as it always did, so the same state survival rules apply. remember alone resets on rotation, rememberSaveable survives it by writing into the saved instance state Bundle, and a ViewModel survives it by construction since the framework retains it across configuration changes.

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

If a screen genuinely shouldn't recreate on rotation at all, that's still handled the traditional way, android:configChanges="orientation|screenSize" in the manifest plus handling it yourself, Compose doesn't add a new mechanism for that, it just means fewer things break when a rotation does happen, since layout adapts to new constraints automatically either way.

How does Jetpack Compose integrate with existing Android frameworks and libraries?

Tier: Less commonDifficulty: Medium

Compose was built to sit on top of the existing Android platform, not replace it, so it integrates with the frameworks and libraries teams already use rather than requiring a parallel set of Compose only tools everywhere.

  • View system. AndroidView embeds a legacy View inside Compose, ComposeView embeds Compose inside a View based screen, so both can coexist in the same app.
  • ViewModel. viewModel() fetches a ViewModel scoped to the current Activity, Fragment, or navigation destination, the same ViewModel class used everywhere else in Android, with no Compose specific version needed.
  • Navigation. The Navigation Compose library builds on the same NavController and back stack concepts as View based Navigation, just with a NavHost and composable destinations instead of fragment destinations.
  • Dependency injection. Hilt has a hiltViewModel() integration for pulling an injected ViewModel straight into a composable.
  • Async and reactive libraries. Flow and StateFlow convert to Compose State with collectAsStateWithLifecycle(), LiveData with observeAsState(), RxJava with subscribeAsState().
  • Image loading. Coil and Glide both ship Compose specific APIs, AsyncImage for Coil, that plug directly into a composable.
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    ProfileContent(uiState)
}

The common thread is that Compose changed the UI layer specifically, but left the rest of the Android architecture, ViewModel, Navigation, Hilt, Room, Retrofit, untouched. That's a deliberate design choice, and it's what let existing apps adopt Compose one screen at a time instead of needing a full architecture rewrite alongside the UI migration.