For the complete documentation index, see llms.txt. This page is also available as Markdown.

Compose Part 2

Q11. Describe the purpose and limitations of CompositionLocal — when is it appropriate to use versus avoid?

CompositionLocal provides a way to pass data implicitly through the Composition tree without manually threading it through every composable parameter. It is the mechanism behind LocalContext, LocalDensity, LocalContentColor, and MaterialTheme. The primary purpose is to provide ambient values that are globally relevant to a subtree, such as theme colors, typography, locale settings, or scroll state. However, the major limitation is that it creates hidden dependencies: a composable that reads a CompositionLocal will behave differently depending on where it is placed in the tree, making the code harder to reason about, test, and preview. If overused for business logic or feature-specific state, it leads to "spaghetti" state management where data flow is opaque. Additionally, non-static CompositionLocal values (those that change frequently) can cause large subtrees to recompose because any read establishes an observation. You should use CompositionLocal for true cross-cutting concerns like theming, insets, or accessibility settings, but avoid it for screen-specific state, ViewModels, or navigation controllers.

// Appropriate: Providing theme-related values or scroll state that many descendants need.
val LocalCustomColors = staticCompositionLocalOf<<CustomColors> { error("No colors provided") }

@Composable
fun AppTheme(content: @Composable () -> Unit) {
    val colors = remember { CustomColors(primary = Color.Blue) }
    CompositionLocalProvider(LocalCustomColors provides colors) {
        MaterialTheme(content = content)
    }
}

@Composable
fun ThemedButton(text: String, onClick: () -> Unit) {
    val colors = LocalCustomColors.current // Clean, expected usage for theme tokens.
    Button(onClick = onClick, colors = ButtonDefaults.buttonColors(containerColor = colors.primary)) {
        Text(text)
    }
}

// Avoid: Using CompositionLocal for business logic or ViewModels.
// val LocalUserViewModel = compositionLocalOf<UserViewModel> { error("Missing") }
// BAD: Hidden dependency makes testing and previewing impossible.

Q12. How would you share state between sibling Composables without passing callbacks through multiple layers?

When two sibling composables need to share the same state, the canonical solution is to hoist that state to their closest common ancestor (a pattern known as "state hoisting"). If the siblings are in the same screen, this could be a parent composable or a shared ViewModel. If the siblings are in different destinations within a NavHost, the best approach is to scope a ViewModel to the parent NavBackStackEntry so that both destinations access the same instance via viewModel(viewModelStoreOwner = parentEntry). This avoids "prop drilling," where callbacks and state are passed through many intermediate layers that do not use them. Another valid but less common approach is using a shared plain class state holder instantiated with remember at the common ancestor. The key principle is that the shared state should live at the lowest common level that encompasses all consumers, ensuring that the UI layer remains a pure function of that single source of truth.


Q13. What is the difference between produceState, collectAsState, and collectAsStateWithLifecycle?

produceState is a bridge for converting non-Flow asynchronous work—such as a suspend function, callback-based API, or LiveData—into a Compose State. It launches a coroutine scoped to the Composition and allows you to manually set the state value inside a block, making it ideal for one-shot operations or complex async initialization. collectAsState is a simpler convenience for Kotlin Flow that collects the flow into a State object. Its critical downside is that it collects the flow continuously regardless of the UI lifecycle, meaning it will keep emitting and recomposing the UI even when the app is in the background, which can waste resources and trigger unnecessary work. collectAsStateWithLifecycle (from androidx.lifecycle:lifecycle-runtime-compose) solves this by respecting the lifecycle of the host LifecycleOwner. It only collects the flow when the lifecycle is at least STARTED (or another specified state), automatically pausing collection when the screen is not visible and resuming when it returns. For any UI-bound StateFlow or Flow, collectAsStateWithLifecycle is the preferred and safest option.


Q14. How do you gracefully handle process death and restoration with Compose Navigation?

Process death occurs when the system kills your app to reclaim memory, and the user later returns via the recent tasks list. Compose Navigation automatically saves and restores the back stack state using the framework's SavedState mechanism, but you must ensure that your destination arguments and ViewModel state are also restorable. For navigation arguments, use primitive types or Parcelable/Serializable in your route definitions so the NavController can persist them in the saved state Bundle. In your ViewModel, use SavedStateHandle to persist UI state that must survive process death; SavedStateHandle integrates directly with the NavBackStackEntry's arguments and saved state. For complex objects, provide a custom Saver or use Kotlin Serialization with type-safe navigation (Compose Navigation 2.8+). Additionally, you can manually call navController.saveState() and restore it via NavHost(rememberNavController(createdState)) if you are managing your own navigation state. The key is to never store non-serializable objects (like Context, Repository instances, or CoroutineScope) in anything that is saved to a Bundle.


Q15. What is the "donut-hole skipping" optimization in Compose, and how do you ensure your code benefits from it?

"Donut-hole skipping" (also called "group skipping" or simply "skipping") is the Compose compiler's ability to skip recomposing a composable and its entire subtree when all of its parameters are stable and have not changed according to equality (==). The name evokes the image of a parent recomposing while the "hole" (the child with stable inputs) remains untouched. To benefit from this optimization, you must ensure that every parameter passed to a composable is of a stable type. Primitives (Int, Boolean, String) and @Immutable data classes are stable. Standard collections like ArrayList, HashMap, or mutable classes are unstable by default and will disable skipping for that composable. You should replace them with Kotlin's immutable interfaces (List, Map) or annotate your data classes with @Immutable. Additionally, avoid capturing unstable variables in lambdas that are passed as parameters; instead, use rememberUpdatedState or pass stable callbacks. The Compose Compiler metrics report (compose_compiler) will flag which composables are "restartable but not skippable," helping you identify exactly where unstable parameters are hurting performance.


Q16. How does LazyColumn differ from RecyclerView in terms of item recycling and composition strategy?

LazyColumn is the Compose equivalent of RecyclerView, but it operates on an entirely different paradigm. RecyclerView recycles physical View instances: when an item scrolls off-screen, its ViewHolder is detached, bound to new data, and reattached. This minimizes object allocation but requires boilerplate (Adapter, ViewHolder, DiffUtil, LayoutManager). LazyColumn, conversely, is a composable that lazily invokes its item content lambdas as the user scrolls. Historically, it did not recycle the underlying composition nodes, meaning new nodes were created for every new item scrolling into view, which could cause performance issues with massive lists. However, modern versions of Compose (1.3+) introduced composition node recycling for LazyColumn, bringing its performance much closer to RecyclerView. The key difference remains the mental model: LazyColumn uses declarative composition—there is no adapter, no findViewById, and state is managed via remember inside the item lambda (scoped to the item's key). LazyColumn also natively supports item animations, spacing, headers, and grids through simple DSL functions (items, stickyHeader, etc.), whereas RecyclerView requires third-party libraries or custom ItemDecoration/ItemAnimator for similar effects.


Q17. What is the cost of using Modifier chains extensively, and how do you optimize them?

Every modifier in a chain (Modifier.fillMaxWidth().padding(16.dp).background(Color.Red).clickable { }) creates a node in the Modifier tree. When the layout system measures, places, and draws the UI, it traverses this tree. A deeply nested or frequently recreated modifier chain increases allocation pressure and traversal time, especially if the chain is constructed inside a loop or a frequently recomposing lambda. The most impactful optimization is to extract static parts of the chain into a remembered or top-level variable so they are not reallocated on every recomposition. If a modifier chain is identical across many items in a LazyColumn, define it outside the item lambda. Additionally, order matters for both semantics and performance: modifiers like padding should generally come before background if you want the background to fill the padded area, but placing expensive modifiers (like graphicsLayer) only where needed reduces render node creation. Avoid creating new Modifier instances inside items or Column blocks unless they depend on dynamic data; instead, apply the base modifier to the container and only override specific properties inside the loop.


Q18. When should you use key in LazyColumn or LazyRow, and what happens if you omit it?

You should use the key parameter in LazyColumn / LazyRow whenever your list items have a stable, unique identifier (such as a database ID or UUID). The key serves as the identity of the item for the lazy layout engine. Without it, Compose uses the item's position (index) as its identity. This causes several problems: first, any remembered state inside the item composable is tied to the position, not the data. If the list is reordered, filtered, or items are inserted/deleted, the state will "jump" to the wrong item (e.g., a checkbox checked for item A will appear on item B after a sort). Second, without keys, LazyColumn cannot efficiently animate item movements or removals because it sees all changes as simple index shifts rather than identity-based moves. Third, it hampers composition recycling because the framework cannot reliably match old compositions to new data items. Always provide key when the list is dynamic, supports reordering, or contains interactive state. For static lists where items never change order or state, omitting key is acceptable but still discouraged as a best practice.


Q19. How do you debug and fix recomposition loops or excessive recompositions in a large screen?

Recomposition loops occur when a composable writes to state during its own composition, which triggers another recomposition, creating an infinite loop. Excessive recompositions happen when large parts of the UI redraw unnecessarily due to unstable parameters or high-level state reads. To debug, first enable Compose Compiler metrics in your build file (reportsDestination) to see which functions are skippable versus restartable. In Android Studio, use the Layout Inspector with "Show Recomposition Counts" enabled to visualize which nodes are recomposing and how often. To fix loops, never write to mutableStateOf directly during the composition body (outside of event handlers like onClick). If you need to derive a value, use derivedStateOf to break the reactive chain. To fix excessive recompositions, apply "donut-hole skipping" principles: pass only stable, primitive data to leaf composables, and read state as far down the tree as possible (localizing state reads). If a high-level object changes frequently but a child only cares about one field, read that field inside the child rather than passing the whole object. Use SideEffect or LaunchedEffect for side effects, not the composition body.


Q20. Explain the impact of using BoxWithConstraints or SubcomposeLayout on performance.

Both BoxWithConstraints and SubcomposeLayout introduce a subcomposition phase during measurement. In standard Compose layout, the composition phase runs first (creating the UI tree), then the measurement phase runs (calculating sizes). BoxWithConstraints needs to know the incoming constraints before it can decide what to compose (e.g., choosing between a Row and a Column based on available width). This means it composes its children inside the measure pass, creating a second composition pass for its content. SubcomposeLayout generalizes this: it allows you to call subcompose(slotId) { ... } during the measure/place block, which is incredibly powerful for custom layouts but inherently more expensive because composition and measurement are interleaved. The impact is most severe inside scrollable containers like LazyColumn, where subcomposition can happen for every visible item during every scroll frame, causing jank. You should prefer standard Layout or pre-calculated modifiers if you already know the constraints from the parent. Only use BoxWithConstraints for top-level responsive switches (e.g., phone vs. tablet layout) and SubcomposeLayout for complex custom layouts that absolutely require composition decisions based on measurement results.

Last updated