20 Most Asked Jetpack Compose Interview Questions for Android Developers

 

1. What is recomposition in Jetpack Compose?
Recomposition is the process where Jetpack Compose re-executes your Composable functions with new data to update the UI hierarchy.
Instead of mutating individual views (like in XML), Compose recreates the UI from scratch for the parts that changed. It uses an intelligent compiler to optimise this process, skipping any Composable functions whose inputs (parameters) have not modified.
2. What triggers recomposition?
Recomposition is triggered exclusively by a change in a state object that a Composable function reads.
    • State Updates: Modifications to a Compose State<T> (e.g., a value wrapped in mutableStateOf).
    • Flow/LiveData Streams: Emitting new values into a StateFlow or LiveData that has been converted to Compose state using .collectAsStateWithLifecycle() or .observeAsState(). 
3. How can you avoid unnecessary recomposition?
You can minimise performance overhead and redundant redraws using these practices: 
    • Use remember: Cache expensive computations inside a Composable so they don’t re-run on every frame. 
    • Pass stable parameters: Ensure your data models are immutable or marked with @Stable / @Immutable annotations so Compose knows it can safely skip them if their references don't change.
    • Use Lambda modifiers: When reading frequently changing state (like scroll offsets), pass a lambda to the modifier (e.g., Modifier.offset { IntOffset(0, scrollState.value) }). This defers state reading to the layout/draw phase, bypassing the recomposition phase entirely.
    • Use derivedStateOf: Buffer high-frequency state updates (explained in Question 7).
4. What is the difference between remember and rememberSaveable?
    • remember: Caches state across recompositions. However, the state is completely lost if the Composable leaves the composition completely or during configuration changes (like screen rotation or changing to dark mode). 
    • rememberSaveable: Caches state across recompositions and survives configuration changes or process death. It automatically saves values into an Android Bundle. It only works for data types that can be put in a Bundle (or custom types using a custom Saver). 
5. What is state hoisting and why is it important?
State hoisting is an architectural pattern where you move UI state up to a Composable’s caller to make the lower Composable stateless. This is achieved by replacing state variables with two parameters: the current value and an event lambda to request changes (e.g., value: String, onValueChange: (String) -> Unit).
Why it is important:
    • Single Source of Truth: Prevents state duplication and sync bugs.
    • Reusability: Stateless components can be rendered anywhere with different data.
    • Testability: You can easily test a stateless Composable by passing mock data and verifying lambda callbacks.
6. What is the difference between mutableStateOf and StateFlow?
    • mutableStateOf: A state container built strictly for the Compose runtime. It is lifecycle-aware by default within the composition tree. When its value changes, it directly schedules a recomposition for any Composable reading it. 
    • StateFlow: A state container from Kotlin Coroutines / architecture layers. It is framework-agnostic (can be used in pure Kotlin, ViewModels, or XML). To use it in Compose, you must explicitly convert it to a Compose state using .collectAsStateWithLifecycle().
7. What is derivedStateOf and where should you use it?
derivedStateOf creates a Compose state that depends on other state objects, acting as a calculation buffer. It guarantees that the calculation executes only when the dependencies change, and it only triggers recomposition if the final output of the calculation changes. 
Where to use it: Use it when a state changes rapidly (high-frequency), but your UI only cares about a condition derived from it.
    • Example: A scroll position updates every single pixel. If you want a "Scroll to Top" button to appear only when scrollPosition > 100, wrapping that condition in derivedStateOf stops the button from constantly recomposing on every pixel scrolled.
8. What is the difference between LaunchedEffect and DisposableEffect?
    • LaunchedEffect: Used for asynchronous side-effects. It launches a coroutine block when it enters the composition. The coroutine cancels automatically if the key changes or if the Composable leaves the screen. 
    • DisposableEffect: Used for synchronous side-effects that require cleanup. It does not offer a coroutine scope. It enforces an onDispose {} block at the end to clean up resources (like unregistering a broadcast receiver, closing a listener, or stopping a sensor) when the key changes or the Composable leaves the screen. 
9. What is a side-effect and when is it useful?
A side-effect is anything that happens outside the control or scope of a Composable function, changing the state of the app outside the UI. Because Composables can execute unpredictably, side-effects must be managed safely inside Compose Effect APIs. 
When it is useful:
    • Fetching data from a network on screen load (LaunchedEffect).
    • Displaying a Snackbar or navigation actions based on a UI state.
    • Initializing and tearing down third-party SDK listeners (DisposableEffect).
10. What is CompositionLocal and how does it work?
CompositionLocal is a tool for passing data down through the Composable tree implicitly, without explicitly defining it as a parameter in every single Composable function (avoiding "prop-drilling"). 
How it works: You create a key provider (using compositionLocalOf or staticCompositionLocalOf). You then wrap a parent UI tree inside a CompositionLocalProvider(LocalYourData provides data) { ... }. Any child or deeply nested grandchild Composable inside that tree can access the data by calling LocalYourData.current. Jetpack Compose uses this internally to pass down global utilities like LocalContextLocalTheme, and LocalSoftwareKeyboardController.
11. What is the difference between Column and LazyColumn?
  • Column (Eager Loading): Measures and renders all its child items simultaneously at layout time, regardless of whether they are visible on the screen.
    • When to use: Use it for small, static layouts with a fixed, predictable number of items (e.g., a settings form, a profile layout).
  • LazyColumn (Lazy Loading): The Compose equivalent of a RecyclerView. It renders and recomposes items only as they scroll into view and recycles their layout structures when they scroll off-screen.
    • When to use: Use it for large, infinite, or dynamically changing lists (e.g., a social media feed, product grids) to prevent memory overhead and UI stuttering. 
12. How should you use a key in LazyColumn?
By default, items in a LazyColumn are tracked by their index position. If you add, delete, or reorder items in the list, Compose will misidentify which item moved, causing unnecessary recompositions or resetting the state of internal UI components. 
Providing a stable and unique identifier using the key parameter fixes this issue.
Code Example : 
        
    @Composable
    fun ProductList(products: List<Product>) {
        LazyColumn {

LazyColumn {
// Pass a unique key (like a database ID) to each item
items(
items = products,
key = { product -> product.id }
) {
) { product ->
ProductRow(product)
}
}
}
ProductRow(product)
}
}
Interview Tip: Always avoid using the item's index position or Random.nextInt() as a key. A valid key must be stable across recompositions and layout passes.
13. What is SnapshotFlow?
snapshotFlow transforms Compose State<T> objects into a standard Kotlin Coroutine Flow. It monitors any Compose state objects read inside its trailing lambda block and emits a new value into the downstream coroutine flow whenever that state changes. 
It acts as the reverse bridge of .collectAsState().
Code Example
kotlin
@Composable
fun InfiniteScrollDetector(lazyListState: LazyListState, onLoadMore: () -> Unit) {
    LaunchedEffect(lazyListState) {
        snapshotFlow { lazyListState.firstVisibleItemIndex }
            .distinctUntilChanged()
            .collect { index ->
                if (index > 20) {
                    onLoadMore() // Safely trigger an asynchronous callback outside Compose
                }
            }
    }
}
14. How do you collect a Flow in Compose?
You should never collect a generic Kotlin Flow directly inside a Composable function because it breaks structured concurrency and causes memory leaks. Instead, you must convert the Flow into a Compose State<T> so that updates automatically trigger recomposition.
There are two primary extension functions to achieve this conversion:
Code Example
@Composable
fun FlowCollectionScreen(viewModel: HomeViewModel) {
    
    // Correct way: Collects safely based on UI lifecycle
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    

    // Legacy way (Not recommended for Android UI development)
    // val oldUiState by viewModel.uiState.collectAsState()

    Text(text = uiState.username)
}
15. What is collectAsStateWithLifecycle and why is it preferred?
collectAsStateWithLifecycle() is a lifecycle-aware state collector specifically designed for Android applications (available via the androidx.lifecycle.lifecycle-runtime-compose library).
Why it is preferred over collectAsState():
    • collectAsState() keeps collecting flow emissions actively even if the app goes to the background, wasting CPU and memory resources.
    • collectAsStateWithLifecycle() automatically drops the underlying coroutine collection when the host Activityor Fragment drops below a specific lifecycle state (by default, Lifecycle.State.STARTED). When the app moves back to the foreground, it seamlessly resubscribes to the stream. 
16. How does navigation work in Jetpack Compose?
Navigation in Compose uses the Navigation Component with Type-Safe APIs (introduced in Navigation 2.8+). It replaces string-based route URLs with concrete Kotlin objects or data classes.
Code Example
kotlin
import kotlinx.serialization.Serializable

// Define destinations as type-safe objects/classes
@Serializable object HomeRoute
@Serializable data class DetailsRoute(val itemId: String)

@Composable
fun MainNavigation() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = HomeRoute) {
        composable<HomeRoute> {
            HomeScreen(onItemClick = { id -> 
                navController.navigate(DetailsRoute(itemId = id)) 
            })
        }
        composable<DetailsRoute> { backStackEntry ->
            // Extract typed argument automatically
            val details: DetailsRoute = backStackEntry.toRoute()
            DetailsScreen(itemId = details.itemId)
        }
    }
}

17. How do you pass arguments between Compose screens?
Using the type-safe Navigation library, you pass arguments by embedding parameters inside the @Serializabledestination class. The library handles the serialization to and from the back stack entry under the hood.
Step 1: Define the target route class with custom arguments
kotlin
@Serializable
data class ProfileRoute(val userId: Int, val isPremium: Boolean)
Step 2: Navigate and pass data
kotlin
navController.navigate(ProfileRoute(userId = 9921, isPremium = true))
Step 3: Extract arguments inside the Destination composable
kotlin
composable<ProfileRoute> { backStackEntry ->
    val profile: ProfileRoute = backStackEntry.toRoute()
    ProfileScreen(userId = profile.userId, isPremium = profile.isPremium)
}
18. How do you handle configuration changes in Compose?
Unlike the legacy XML view system, Compose does not rely on recreating the whole view layout from a configuration folder (like layout-land). Instead:
    1. Layout Adapts Electronically: The UI relies on layout bounds (e.g., BoxWithConstraints) to change design automatically when size properties flex.
    2. State Retention: Local Composable states are retained across rotations using rememberSaveable (saves state to a Bundle) or inside architectural ViewModels (survives rotation entirely).
Code Example
kotlin
@Composable
fun AdaptiveScreen() {
    // Survives screen rotation safely
    var textInput by rememberSaveable { mutableStateOf("") }

    BoxWithConstraints {
        if (maxWidth < 600.dp) {
            PortraitLayout(textInput, onValueChange = { textInput = it })
        } else {
            LandscapeLayout(textInput, onValueChange = { textInput = it })
        }
    }
}

19. What are Stable and Immutable objects in Compose?
Compose utilizes these markers to identify types that are guaranteed not to change unexpectedly, allowing the runtime compiler to skip recomposition for optimized performance. 
    • @Immutable: Applied to classes where all properties are completely immutable (val) and can never modify after construction (e.g., a standard data class). 
    • @Stable: Applied to classes whose properties can mutate, but the Compose runtime will be explicitly notified via standard observable states when mutations occur.
Code Example
kotlin
@Immutable
data class UserProfile(
    val name: String,
    val email: String
) // Compose safely skips UI blocks rendering this object if references match

@Stable
class RealtimeClockState {
    var currentTimeMillis by mutableStateOf(0L)
} // Modifies internally, but informs Compose cleanly when changes happen

Crucial Interview Insight: Standard Kotlin Collections (ListMap) are inferred as Unstable by the Compose compiler because underlying implementations (like ArrayList) are mutable. Wrap lists using Kotlinx Immutable Collections (ImmutableList) or annotate your wrapper models with @Stable.
20. How do you optimize Compose UI performance in a large app?
  1. Deconstruct with Lambdas (Defer State Reads):Pass high-frequency states inside lambda blocks directly to modifiers rather than raw values to avoid full recomposition loops.
    kotlin
    // Bad: Recomposes full view scope on every pixel scrolled
    Modifier.offset(y = scrollState.value.dp)
    
    // Optimized: Bypasses composition phase; executes straight in Layout/Draw phase
    Modifier.offset { IntOffset(0, scrollState.value) }
  2. Explicit Keying: Use unique keys in all LazyColumn / LazyRow items to optimize positional calculations.[1]
  3. Encapsulate inside derivedStateOf: Buffer high-frequency input streams into discrete state updates before letting them hit visual components.
  4. Enforce Multi-Module Stability: Add a compose_compiler.cfg configuration file to enforce type stability across separate code modules in large architectures.
  5. Profile via Layout Inspector: Utilize Android Studio's Layout Inspector regularly to track recomposition counters and isolate performance bottlenecks. [12]

 

Comments

Popular posts from this blog

Most Relevant Android Interview Questions 2025

Cheat Sheet for Kotlin