Jetpack Compose foundations — declarative UI
Declarative means state describes UI
In Views, code often finds widgets and mutates them:
progressBar.isVisible = state.loading
errorText.isVisible = state.error != null
errorText.text = state.error
In Compose, a function describes what should exist for the current state:
@Composable
fun CheckoutScreen(state: CheckoutUiState) {
when {
state.loading -> CircularProgressIndicator()
state.error != null -> ErrorMessage(state.error)
else -> OrderSummary(state.order)
}
}
State changes; Compose reruns relevant composable scopes and updates the UI tree. You do not manually keep dozens of widget setters synchronized.
:::warning Android-only chapter Compose runtime/UI/Material examples require an Android Compose project and cannot run in Kotlin Playground. :::
Composition and recomposition
- Initial composition: Compose runs composables and creates a Composition describing UI.
- Recomposition: Compose reruns scopes whose observed inputs may have changed and applies differences.
Recomposition is not “redraw the entire screen” and not “recreate every Android View.” Compose can skip unchanged work and rerun smaller scopes.
State write
↓
Compose invalidates reading scope
↓
Recomposition calculates desired changes
↓
Composition updated
↓
layout/draw phases run only where required
Composables must be repeatable
The official Compose mental model explains that recomposition can be skipped, run often, run in different order, or be cancelled.
❌ Side effect in composition
@Composable
fun ReceiptScreen(viewModel: ReceiptViewModel) {
viewModel.loadReceipt() // Can run repeatedly or from discarded composition.
Text("Loading")
}
✅ Event/effect ownership
Loading belongs in ViewModel initialization, an explicit user event, or a correctly keyed effect discussed in Chapter 15.
@Composable
fun ReceiptScreen(
state: ReceiptUiState,
onRetry: () -> Unit
) {
// Pure description from parameters.
}
Stateless and stateful APIs
A reusable component should usually expose value and events:
@Composable
fun QuantityPicker(
quantity: Int,
onQuantityChange: (Int) -> Unit,
modifier: Modifier = Modifier
) {
Row(modifier) {
Button(onClick = { onQuantityChange(quantity - 1) }) { Text("−") }
Text(quantity.toString())
Button(onClick = { onQuantityChange(quantity + 1) }) { Text("+") }
}
}
The parent owns source of truth. This supports previews, tests, process reconstruction, and unidirectional flow.
UI function API design
Good composable APIs:
- accept immutable/observable values rather than repositories;
- emit intent callbacks rather than mutate globals;
- accept
modifier: Modifier = Modifierfor caller layout/semantics; - make required/optional slots clear;
- avoid Activity/NavController/ViewModel in leaf components;
- use semantic names (
onCheckout, notcallback).
Compose phases
Composition: what UI nodes should exist?
Layout: what size and position?
Drawing: what pixels/content to render?
Reading rapidly changing state in composition can recompose; reading it only in layout/draw APIs where possible can defer work to a later phase. This is an advanced optimization—first build correct state boundaries and measure.
Preview is not a runtime test
@Preview
@Composable
private fun CheckoutPreview() {
AppTheme {
CheckoutScreen(state = sampleCheckoutState)
}
}
Previews improve component iteration and state coverage but do not prove navigation, lifecycle, performance, accessibility services, or device integration.
Practical mistakes
❌ Pass ViewModel into every child
This hides dependencies/state and makes previews/tests/navigation scope harder.
✅ Screen route owns ViewModel; content receives state/events
@Composable
fun CheckoutRoute(viewModel: CheckoutViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
CheckoutScreen(state, viewModel::submit)
}
❌ Build state from unrelated booleans
loading=true, error!=null, and content!=null can conflict.
✅ Model mutually exclusive screen states
sealed interface CheckoutUiState {
data object Loading : CheckoutUiState
data class Content(val order: OrderUi) : CheckoutUiState
data class Error(val type: ErrorType) : CheckoutUiState
}
Compose is declarative: state is input to composable functions that describe UI. Initial composition creates the Composition; recomposition reruns invalidated scopes and can skip/cancel work. Therefore composables must be fast, repeatable, and side-effect free. I hoist source of truth, pass immutable state and intent callbacks, keep ViewModels at route boundaries, and model impossible UI-state combinations out of the type system.