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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
Navigation in Compose
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.
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.
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.
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.
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.