๐ŸŽจ Core Concept ยท ~40 min read ยท Beginner โ†’ Advanced

Jetpack Compose

Android's modern UI toolkit โ€” how declarative UI works, why recomposition is the key idea, and how state, side effects, and performance fit together into a system you can reason about.

The problem with XML views

For over a decade, Android UI was built with XML layouts and View classes. You'd describe your layout in XML, inflate it at runtime, then imperatively manipulate it from code โ€” textView.text = "Hello", button.isEnabled = false, recyclerView.adapter = MyAdapter(items). It worked, but the model had a fundamental flaw: the XML defined one state of the UI, and your Kotlin code was responsible for keeping every view synchronized with every possible state your app could be in.

As screens grew complex, this became unmanageable. Imagine a screen with a loading state, an empty state, an error state, a success state with 15 different UI elements โ€” and each element might have multiple sub-states. The number of combinations explodes. You'd end up with sprawling updateUI() functions, forgotten View.GONE calls that left phantom views visible, and bugs that only appeared in rare state transitions nobody had thought to test.

The root cause: with imperative UI, the UI is stateful and mutable. Every view holds its own state. Your job is to keep all that mutable state in sync with your data model. It's like trying to keep 50 whiteboards in sync โ€” every time one changes, you have to update all the others manually.

๐Ÿฝ๏ธ Analogy

Imperative UI is like giving a waiter a list of instructions: "Go to table 4. Change the glass. Add a napkin. Remove the bread plate. Update the menu." Declarative UI is like saying: "Table 4 should look like this." You describe the end state โ€” the system figures out the diff and applies it. Much harder to get wrong.

Declarative UI โ€” describe what, not how

Jetpack Compose takes a different approach. Instead of mutating views, you write functions that describe what the UI should look like given the current state. When state changes, Compose re-runs those functions โ€” or the relevant parts of them โ€” and figures out what changed. You never touch the old UI to update it. You just describe the new one.

๐Ÿ“‹ Imperative vs Declarative UI โ€” the mental model shift
IMPERATIVE (XML Views) State changes โ†’ btnBuy.isEnabled = false progressBar.visibility = VISIBLE errorText.visibility = GONE priceLabel.text = "โ‚น999" ๐Ÿ˜ฐ You manage every view's state manually DECLARATIVE (Compose) State changes โ†’ @Composable fun Screen( state: UiState ) { /* describe UI */ } Compose diffs & redraws UI = f(state) UI is a pure function of state ๐Ÿ˜Œ Describe what it looks like. Done.

This model is called declarative UI. The UI is a pure function of state: UI = f(state). Give it a state object, get back a UI description. Give it a different state object, get back a different UI. No manual bookkeeping, no forgotten updates, no impossible state combinations โ€” because the UI can only ever reflect what you described for each state.

Composable functions

In Compose, the building block of UI is the composable function โ€” a regular Kotlin function annotated with @Composable. Composable functions describe a piece of UI. They can call other composable functions. They can hold state. They can respond to user input. And critically, they can be recomposed โ€” re-executed โ€” when their inputs change.

ProductCard.kt โ€” a composable function
@Composable fun ProductCard( product: Product, onBuyClick: () -> Unit, modifier: Modifier = Modifier ) { Card( modifier = modifier.fillMaxWidth().padding(16.dp), elevation = CardDefaults.cardElevation(4.dp) ) { Column(modifier = Modifier.padding(16.dp)) { Text( text = product.name, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(8.dp)) Text( text = "โ‚น${product.price}", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(12.dp)) Button( onClick = onBuyClick, enabled = product.inStock, modifier = Modifier.fillMaxWidth() ) { Text(if (product.inStock) "Buy Now" else "Out of Stock") } } } } // Usage โ€” just call it like a function @Composable fun ProductScreen(product: Product, vm: ProductViewModel) { ProductCard( product = product, onBuyClick = { vm.onBuyClicked() } ) }

A few things to notice. The composable is a plain Kotlin function โ€” no class to extend, no lifecycle methods to override, no XML to inflate. Composition is just nesting function calls. The button text and enabled state are computed inline from product.inStock โ€” no separate updateButton() call needed. And the modifier is passed in from outside, which is the Compose convention for letting callers control layout and appearance.

๐Ÿ“Œ Composable Rules

Composable functions have three special rules enforced by the compiler: (1) They can only be called from other composable functions. (2) They should be side-effect free โ€” no network calls, no database access directly. (3) They must be idempotent โ€” calling the same composable with the same inputs should produce the same UI every time. These rules exist to make recomposition safe and predictable.

Recomposition โ€” the heart of Compose

When state changes, Compose doesn't throw away the entire UI and rebuild it from scratch. Instead, it recomposes โ€” it re-executes only the composable functions whose inputs have changed, and skips everything else. This is the key performance mechanism in Compose, and understanding it is essential for writing efficient UIs.

When you first run your composables, Compose builds a tree โ€” the composition. Each node is a composable that was called. Compose remembers this tree along with the inputs that produced it. When state changes, it re-runs the affected composables and compares the new output to the old tree. Nodes that haven't changed are skipped entirely.

๐Ÿ“‹ Recomposition โ€” only the changed subtree re-runs
Before: price changes ProductScreen TopBar ProductCard Title Price Button price changes After: only Price recomposes ProductScreen skipped โœ“ TopBar skipped โœ“ ProductCard skipped โœ“ Title skipped โœ“ Price recomposed โ†บ Button skipped โœ“ Compose only re-runs composables whose inputs changed โ€” the rest are skipped

For recomposition to work correctly, Compose needs to determine whether a composable's inputs have changed. It does this via equality checks. If the parameters passed to a composable are equal to what they were last time, Compose marks that composable as "skippable" and skips re-executing it. This is why stability matters so much โ€” if Compose can't prove a type is stable (i.e., that equals() is reliable), it won't skip recomposition even when inputs haven't logically changed.

โš ๏ธ Recomposition Trap โ€” unstable types

A class is considered unstable by the Compose compiler if it has mutable public properties or if the compiler can't verify its equality contract. Passing an unstable type as a parameter prevents skipping โ€” meaning that composable will always recompose when its parent does, even if nothing actually changed. Common culprits: List<T> (use ImmutableList from Kotlinx), data classes with mutable fields, and classes from third-party libraries.

State in Compose

State is any value that, when it changes, should cause the UI to update. In Compose, state is tracked using remember and mutableStateOf. The combination is what makes Compose reactive: when the value in a MutableState changes, Compose knows exactly which composables read that value and schedules them for recomposition.

State basics โ€” remember and mutableStateOf
@Composable fun Counter() { // remember: survives recomposition (value isn't reset on re-execution) // mutableStateOf: tells Compose to watch this value and recompose when it changes var count by remember { mutableStateOf(0) } Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(text = "Count: $count", style = MaterialTheme.typography.headlineMedium) Button(onClick = { count++ }) { Text("Increment") } } } // rememberSaveable โ€” also survives configuration changes (rotation, process death) @Composable fun SearchBar() { var query by rememberSaveable { mutableStateOf("") } TextField( value = query, onValueChange = { query = it }, placeholder = { Text("Search...") } ) } // Collecting StateFlow from ViewModel โ€” the production pattern @Composable fun ProductScreen(vm: ProductViewModel = hiltViewModel()) { val uiState by vm.uiState.collectAsStateWithLifecycle() when (uiState) { is ProductUiState.Loading -> CircularProgressIndicator() is ProductUiState.Success -> ProductCard(uiState.product) is ProductUiState.Error -> ErrorScreen(uiState.message) } }

The difference between remember and rememberSaveable is important. remember survives recomposition but is lost on rotation (because rotation destroys and recreates the Composable's scope). rememberSaveable saves its value to the saved instance state bundle โ€” same mechanism as onSaveInstanceState() in the old world. For most screen-level state, though, you should use a ViewModel with collectAsStateWithLifecycle(), which handles all of this correctly and also respects lifecycle stopping.

State hoisting

A composable that owns its own state (like our Counter above) is called stateful. It's convenient for simple cases, but it has a problem: you can't reuse it, you can't test it easily, and you can't have a parent react to its state. The solution is state hoisting โ€” moving state up to the caller, and passing it back down as a parameter alongside a callback to modify it.

๐Ÿ“‹ State hoisting โ€” move state up, pass callbacks down
โŒ Stateful โ€” hard to reuse SearchBar() var query by remember { mutableStateOf("") } Parent can't read the query. Can't pre-fill it from ViewModel. Can't test it in isolation. โœ… Stateless โ€” easily reused & tested SearchScreen (parent) var query by remember { mutableStateOf("") } value = query onValueChange = { query = it } SearchBar (stateless child) fun SearchBar(value, onValueChange) โœ“ Parent can read query โœ“ Pre-fill from ViewModel โœ“ Test by passing any value
State hoisting in practice
// โœ… Stateless โ€” takes value + callback, owns nothing @Composable fun SearchBar( query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier ) { TextField( value = query, onValueChange = onQueryChange, placeholder = { Text("Search products...") }, modifier = modifier ) } // Parent hoists the state โ€” can pass it anywhere, including to ViewModel @Composable fun SearchScreen(vm: SearchViewModel = hiltViewModel()) { val query by vm.query.collectAsStateWithLifecycle() val results by vm.results.collectAsStateWithLifecycle() Column { SearchBar( query = query, onQueryChange = { vm.onQueryChanged(it) } ) LazyColumn { items(results) { product -> ProductCard(product = product, onBuyClick = { vm.onBuyClicked(product.id) }) } } } }

The three phases of Compose

Every frame, Compose goes through three phases in order: Composition โ†’ Layout โ†’ Drawing. Understanding this pipeline is key to understanding where performance problems come from and how to avoid them.

๐Ÿ“‹ Compose render pipeline โ€” three phases per frame
โ‘  Composition Run @Composable fns Build the UI tree Track state reads Output: slot table โ‘ก Layout Measure children Decide sizes Place on screen Output: positions + sizes โ‘ข Drawing Emit draw commands Canvas / RenderNode GPU renders frame Output: pixels on screen โœ“ frame rendered Recomposition only re-runs Phase โ‘ . If only layout changes, only Phase โ‘ก re-runs. Drawing reruns for animations.

This pipeline matters because Compose can skip phases. If state only affects how something is drawn (say, its color during an animation), Compose can skip Composition and Layout entirely and go straight to Drawing. This is why Modifier.drawWithContent and Modifier.graphicsLayer-based animations are faster than rebuilding composables โ€” they operate in the Drawing phase, not the Composition phase.

Side effects

Composable functions should be side-effect free โ€” they describe UI, they don't do things. But real apps need to do things: start coroutines, subscribe to events, initialize analytics, navigate. Compose provides a family of effect handlers that let you run side effects in a controlled, lifecycle-aware way.

๐Ÿ“‹ Side effect APIs โ€” when to use each one
LaunchedEffect(key) Launches a coroutine when composable enters composition. Re-runs when key changes. Cancelled when it leaves composition. โ†’ fetch data on entry, react to key change (userId, tab index) DisposableEffect(key) Like LaunchedEffect but with a cleanup block (onDispose). Use when you need to unregister/clean up (listeners, sensors). โ†’ register lifecycle observer, subscribe to event bus, setup sensor SideEffect Runs after every successful recomposition. Use to sync Compose state with non-Compose code. โ†’ update analytics SDK, sync with legacy View system rememberCoroutineScope() Returns a CoroutineScope tied to the composable's lifetime. Use to launch coroutines in response to user events. โ†’ trigger scroll, show snackbar on button click produceState Converts non-Compose async sources into Compose State. Provides a coroutine scope; returns State<T>. โ†’ observe a Flow or callback-based API as Compose state derivedStateOf { } Computes a value from other state. Recomposition only triggers when the computed result changes (not when inputs change). โ†’ "show scroll-to-top button when scrolled past 100 items" Rule of thumb: if you're launching a coroutine in a composable, you need one of these
Side effects in practice
@Composable fun UserProfileScreen(userId: String, vm: ProfileViewModel = hiltViewModel()) { // LaunchedEffect: load data when userId changes LaunchedEffect(userId) { vm.loadProfile(userId) // runs once per userId, cancels old job if userId changes } // DisposableEffect: observe lifecycle events val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.onScreenResumed() } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) // cleanup } } // derivedStateOf: expensive computation, only recompose when result changes val listState = rememberLazyListState() val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 5 } } // Without derivedStateOf, every scroll would recompose this entire screen // With it, only the composable reading showScrollToTop recomposes // rememberCoroutineScope: launch on button click (event-driven, not lifecycle-driven) val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } Button(onClick = { scope.launch { snackbarHostState.showSnackbar("Profile saved!") } }) { Text("Save") } }

Modifiers

Modifiers are how you control a composable's appearance, size, behavior, and interaction in Compose. Unlike View attributes (which are baked into each View class), modifiers are a composable, chainable decoration system โ€” you build up a modifier chain, and it's applied to the composable in order from left to right (or top to bottom).

Order matters in a modifier chain. Modifier.padding(16.dp).background(Red) applies padding first, then the red background fills the padded area โ€” so the padding is inside the background. Reverse it (background(Red).padding(16.dp)) and the background covers the full area, with padding creating space inside. Same two modifiers, opposite visual result.

Modifiers โ€” order matters, every interaction goes here
Box( modifier = Modifier .fillMaxWidth() // size .height(56.dp) .background( // appearance โ€” after size, before padding color = MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(12.dp) ) .clickable { onCardClick() } // interaction .padding(horizontal = 16.dp) // inner padding โ€” applied AFTER background .semantics { // accessibility contentDescription = "Product card: ${product.name}" } ) { // content inside the Box } // Conditional modifier โ€” use extension function pattern fun Modifier.thenIf(condition: Boolean, block: Modifier.() -> Modifier): Modifier = if (condition) then(block()) else this // Usage: Text( text = product.name, modifier = Modifier.thenIf(product.isOnSale) { background(Color.Yellow, RoundedCornerShape(4.dp)) } )

Performance โ€” writing Compose that stays fast

Compose is fast by default. But you can make it slow by giving it too much work to do on each recomposition. The main lever is reducing unnecessary recompositions โ€” making sure only the composables that truly need to update actually re-run.

Stability and @Stable

As mentioned earlier, Compose skips recomposing a function if all its parameters are equal to last time. But it can only make that determination if the parameters are stable โ€” meaning their equals() method reliably reflects whether the value has changed. Data classes with only immutable properties are stable. Classes with mutable fields, or classes the compiler can't analyze (like those from other modules), are considered unstable and won't be skipped.

Stability โ€” making your classes skippable
// โŒ Unstable โ€” List is mutable, Compose won't skip recomposition @Composable fun ProductList(products: List<Product>) { /* ... */ } // โœ… Option 1: Use ImmutableList from kotlinx.collections.immutable @Composable fun ProductList(products: ImmutableList<Product>) { /* ... */ } // โœ… Option 2: Annotate with @Immutable โ€” you promise no public fields change @Immutable data class ProductUiState( val products: List<Product>, val isLoading: Boolean ) // โœ… Option 3: derivedStateOf for expensive derived computations @Composable fun CartSummary(items: List<CartItem>) { // Without derivedStateOf: recalculates total on every recomposition // With it: only recalculates when items actually changes val total by remember(items) { derivedStateOf { items.sumOf { it.price * it.quantity } } } Text("Total: โ‚น$total") } // โœ… key() in LazyColumn โ€” helps Compose identify items across recompositions LazyColumn { items(products, key = { it.id }) { product -> // stable key = efficient diff ProductCard(product) } }
โœ… Performance Checklist

Use key() in LazyColumn โ€” prevents item re-creation on list changes.
Prefer derivedStateOf for values computed from other state โ€” avoids cascading recompositions.
Use ImmutableList or @Immutable on UI state classes โ€” makes composables skippable.
Avoid reading state high up โ€” read state as close to where it's used as possible (lambda deferral).
Use Modifier.graphicsLayer for animations โ€” stays in Drawing phase, skips Composition entirely.

Jetpack Navigation Compose replaces Fragment back stacks with a graph of composable destinations. You define routes as strings (or typed objects with Safe Args), a NavHost handles the back stack, and a NavController lets you navigate programmatically.

Navigation setup โ€” NavHost, routes, and type-safe args
// Define your routes as a sealed class for type safety sealed class Screen(val route: String) { object ProductList : Screen("product_list") data class ProductDetail(val productId: String) : Screen("product/{productId}") { fun createRoute(id: String) = "product/$id" } object Cart : Screen("cart") } // NavHost โ€” the root of your nav graph @Composable fun AppNavGraph(navController: NavHostController = rememberNavController()) { NavHost(navController = navController, startDestination = Screen.ProductList.route) { composable(Screen.ProductList.route) { ProductListScreen(onProductClick = { id -> navController.navigate(Screen.ProductDetail("").createRoute(id)) }) } composable( route = Screen.ProductDetail("").route, // "product/{productId}" arguments = listOf(navArgument("productId") { type = NavType.StringType }) ) { backStackEntry -> val productId = backStackEntry.arguments?.getString("productId") ?: return ProductDetailScreen(productId = productId, onAddToCart = { navController.navigate(Screen.Cart.route) }) } composable(Screen.Cart.route) { CartScreen() } } } // Navigate from ViewModel events โ€” collect in LaunchedEffect @Composable fun ProductDetailScreen(productId: String, onAddToCart: () -> Unit) { val vm: ProductViewModel = hiltViewModel() LaunchedEffect(Unit) { vm.events.collect { event -> when (event) { is ProductEvent.NavigateToCart -> onAddToCart() } } } }

Interop โ€” mixing Compose and Views

Most production apps don't migrate to Compose overnight. Compose provides two interop mechanisms for living in a mixed world. ComposeView embeds Compose inside a traditional View hierarchy โ€” you can add it to your XML layout or create it programmatically. AndroidView goes the other direction โ€” it embeds a traditional View inside a Compose tree. Both support full lifecycle integration.

Compose โ†” View interop
// 1. Embed Compose inside a Fragment (migration starting point) class ProductFragment : Fragment() { override fun onCreateView(...): View = ComposeView(requireContext()).apply { setContent { MaterialTheme { val vm: ProductViewModel by viewModels() ProductScreen(vm = vm) } } } } // 2. Use a traditional View inside Compose (e.g., MapView, PlayerView) @Composable fun VideoPlayer(url: String) { AndroidView( factory = { context -> PlayerView(context).also { player -> player.player = ExoPlayer.Builder(context).build() } }, update = { playerView -> // called on recomposition โ€” update the View with new state playerView.player?.setMediaItem(MediaItem.fromUri(url)) }, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f) ) }

Testing Compose UI

Compose UI tests use createComposeRule() to host composables in a test environment โ€” no emulator required for most tests, though you'll need a device or emulator for screenshot testing. The test API is semantic: you find composables by their content description or text, not by view IDs.

ProductCardTest.kt โ€” composable UI test
class ProductCardTest { @get:Rule val rule = createComposeRule() @Test fun `buy button disabled when out of stock`() { val product = Product(id = "1", name = "Laptop", price = 999.0, inStock = false) rule.setContent { ProductCard(product = product, onBuyClick = {}) } rule.onNodeWithText("Out of Stock").assertIsDisplayed() rule.onNodeWithText("Out of Stock").assertIsNotEnabled() } @Test fun `buy click triggers callback`() { var clicked = false val product = Product(id = "1", name = "Laptop", price = 999.0, inStock = true) rule.setContent { ProductCard(product = product, onBuyClick = { clicked = true }) } rule.onNodeWithText("Buy Now").performClick() assertThat(clicked).isTrue() } @Test fun `product name is shown`() { val product = Product(id = "1", name = "MacBook Pro", price = 150000.0, inStock = true) rule.setContent { ProductCard(product = product, onBuyClick = {}) } rule.onNodeWithText("MacBook Pro").assertIsDisplayed() rule.onNodeWithText("โ‚น150000.0").assertIsDisplayed() } }

How this connects to system design interviews

When you're asked to design a complex Android screen โ€” a social feed, a shopping cart, a live order tracker โ€” Compose changes the answer in concrete ways. State management becomes: "I'd model the screen as a sealed UiState class, expose it as a StateFlow from the ViewModel, and collect it in the composable with collectAsStateWithLifecycle()." Handling live updates becomes: "I'd use a LaunchedEffect to observe a WebSocket flow and update the ViewModel state when messages arrive." Performance on a long list becomes: "LazyColumn with stable keys and items wrapped in @Immutable data classes to make them skippable."

The most common mistake interviewees make with Compose in system design is describing it at the wrong level โ€” talking about XML alternatives rather than the mental model shift. Compose isn't just "a nicer way to write layouts." It's a reactive programming model where UI is derived from state. The architecture decisions that follow from that model โ€” unidirectional data flow, single source of truth, state as the source of UI truth โ€” are what interviewers want to hear.

๐ŸŽฏ Interview Summary

UI = f(state) โ€” UI is a pure function of state. Compose re-runs composable functions when state changes.
Recomposition โ€” only composables whose inputs changed re-run. Stability determines skippability.
remember / rememberSaveable โ€” survives recomposition / survives rotation. ViewModel survives process death.
State hoisting โ€” move state up to make composables reusable, testable, and controlled by parent.
Three phases โ€” Composition โ†’ Layout โ†’ Drawing. Each can be skipped independently.
LaunchedEffect(key) โ€” scoped coroutine, re-runs on key change, cancelled on leave.
derivedStateOf โ€” only recomposes when computed result changes, not when source state changes.
Performance โ€” ImmutableList, @Immutable, stable keys in LazyColumn, lambda deferral.