Jetpack Compose Fundamentals: Composable Functions, State & Recomposition
Android's modern declarative UI — the future of Android development
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Jetpack Compose is Android's modern declarative UI toolkit. Instead of imperatively modifying Views, you describe the UI as a function of state using @Composable functions. When state changes, Compose automatically recomposes only the affected parts of the UI tree. Modifier controls layout and appearance. remember/rememberSaveable manages local state. LazyColumn replaces RecyclerView. Material3 provides a complete design system. LaunchedEffect handles coroutine-based side effects tied to composition lifecycle.
Real-world relevance
In a fintech SaaS app migrating from XML to Compose, the TransactionListScreen is a new full-Compose screen while legacy AccountDetailActivity still uses XML. TransactionListScreen uses LazyColumn with key=transaction.id for smooth animated updates when new transactions arrive. A search bar uses state hoisting — the query string lives in the ViewModel (StateFlow), the SearchBar composable is stateless, receiving value and onValueChange as parameters. LaunchedEffect(Unit) triggers initial data load. The app uses Material3 Scaffold with a TopAppBar and FAB for new transaction entry.
Key points
- @Composable functions — Any function annotated with @Composable can emit UI. Composables are not classes — they are functions that describe UI as a function of state. They can only be called from other @Composable functions or from a Composition entry point (setContent, ComposeView). The Compose runtime tracks which composables ran and with what parameters.
- Recomposition — When state changes, Compose re-executes only the composable functions whose inputs changed — not the entire UI tree. Compose compares parameters using structural equality (equals()). If nothing changed, the composable is skipped. This is called 'smart recomposition'. Composables must be idempotent and side-effect free for correct recomposition.
- State — remember and mutableStateOf — var count by remember { mutableStateOf(0) } creates state that persists across recompositions. Without remember, state is reset on every recomposition. mutableStateOf creates a State object — reading its value inside a composable subscribes that composable to changes. When value changes, subscribed composables recompose.
- rememberSaveable — Like remember, but also survives configuration changes and process death — uses the same Bundle mechanism as onSaveInstanceState. Use rememberSaveable for UI state that should survive rotation: scroll position, selected tab, user input. For complex objects, implement Saver or use @Parcelize data classes.
- State hoisting — Moving state UP from a composable to its caller — the composable becomes stateless and receives state + callbacks as parameters. Pattern: (value, onValueChange) -> Unit. Stateless composables are more reusable, testable, and previewable. Rule: hoist state to the lowest common ancestor of all composables that need it.
- Modifier — Modifier is an immutable, ordered list of instructions applied to a composable — size, padding, background, click handling, drawing, layout behavior. Order matters: Modifier.padding(16.dp).background(Color.Blue) pads THEN draws background (background inside padding). Modifier.background(Color.Blue).padding(16.dp) draws background THEN pads (background outside padding).
- Column, Row, Box — Column: arranges children vertically (like vertical LinearLayout). Row: horizontal (like horizontal LinearLayout). Box: stacks children (like FrameLayout) — last child drawn on top. All support Arrangement (spacing between children) and Alignment (cross-axis positioning). These are the fundamental layout composables.
- LazyColumn and LazyRow — Compose equivalents of RecyclerView — only compose and lay out items currently visible. items(list) and itemsIndexed(list) populate items. No Adapter or ViewHolder needed — just describe each item as a composable. LazyListState.animateScrollToItem() for programmatic scrolling. Use key parameter in items() for stable IDs and better animations.
- Material3 components — Jetpack Compose ships with Material3 (Material Design 3) components: Scaffold (app structure with topBar, bottomBar, FAB, snackbarHost), TopAppBar, NavigationBar, Card, Button, OutlinedTextField, Chip, Dialog. Use MaterialTheme.colorScheme, MaterialTheme.typography for theming instead of hardcoded colors.
- Side effects — LaunchedEffect — LaunchedEffect(key) launches a coroutine tied to the composition. Runs when the composable enters composition and re-runs if key changes. Use for one-time effects (load data, animate on first show, trigger navigation). The coroutine is cancelled when the composable leaves composition or key changes.
- Stability and performance — Compose skips recomposition of a composable if all its parameters are 'stable' (implements equals() correctly) and haven't changed. Data classes are stable. Standard Kotlin collections (List, Map) are NOT stable — use Kotlin ImmutableList from kotlinx-collections-immutable or wrap in @Stable/@Immutable annotated classes for performance-critical composables.
- Interop — ComposeView and AndroidView — ComposeView embeds Compose inside XML layouts — migrate screens incrementally. AndroidView embeds legacy Views inside Compose — wrap a MapView, WebView, or any View with no Compose equivalent. This enables gradual migration of existing apps without a full rewrite.
Code example
// State hoisting pattern — stateless composable
@Composable
fun TransactionListScreen(
viewModel: TransactionViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
TransactionListContent(
uiState = uiState,
searchQuery = searchQuery,
onSearchQueryChange = viewModel::onSearchQueryChanged,
onTransactionClick = viewModel::onTransactionSelected,
onRetry = viewModel::loadTransactions
)
}
// Pure stateless composable — reusable, testable, previewable
@Composable
fun TransactionListContent(
uiState: TransactionUiState,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
onTransactionClick: (Transaction) -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
Scaffold(
topBar = {
TopAppBar(title = { Text("Transactions") })
},
modifier = modifier
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Search bar — stateless, controlled by parent
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
label = { Text("Search transactions") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
when (uiState) {
is TransactionUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is TransactionUiState.Success -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp)
) {
items(
items = uiState.transactions,
key = { it.id } // Stable keys for smooth animations
) { transaction ->
TransactionCard(
transaction = transaction,
onClick = { onTransactionClick(transaction) }
)
}
}
}
is TransactionUiState.Error -> {
ErrorScreen(
message = uiState.message,
onRetry = onRetry
)
}
}
}
}
}
@Composable
fun TransactionCard(
transaction: Transaction,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = transaction.description,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = transaction.date,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = transaction.formattedAmount,
style = MaterialTheme.typography.titleMedium,
color = if (transaction.isCredit) Color.Green else Color.Red
)
}
}
}
// LaunchedEffect for side effects
@Composable
fun TransactionDetailScreen(transactionId: String, viewModel: TransactionViewModel = hiltViewModel()) {
LaunchedEffect(transactionId) { // Re-runs if transactionId changes
viewModel.loadTransactionDetail(transactionId)
}
// ... rest of UI
}Line-by-line walkthrough
- 1. TransactionListScreen is the stateful screen composable — it connects the ViewModel to the stateless UI. collectAsStateWithLifecycle() is lifecycle-aware — it stops collecting when the app goes to background, saving battery.
- 2. TransactionListContent receives only plain data and callbacks — it has zero knowledge of ViewModel, Flow, or lifecycle. This makes it @Preview-able and testable with pure data.
- 3. Scaffold provides Material3 app structure — topBar, FAB, snackbarHost, and content with correct padding insets. The paddingValues from the lambda MUST be applied to content to avoid overlap with system bars.
- 4. The when(uiState) block handles all three states exhaustively (sealed class). Compose recomposes only the relevant branch when uiState changes — Loading → Success transition only recomposes the LazyColumn area.
- 5. items(transactions, key = { it.id }) gives Compose stable identity for each transaction. When new transactions arrive, Compose animates additions/removals correctly and preserves any per-item state (like expanded/collapsed).
- 6. TransactionCard is fully stateless — it receives transaction data and an onClick lambda. Card uses the Material3 Card composable which handles elevation, shape, and ripple automatically.
- 7. Modifier.weight(1f) on the Column inside Row makes the text column expand to fill remaining space after the amount Text has taken its natural width — equivalent to layout_weight=1 in LinearLayout.
- 8. LaunchedEffect(transactionId) runs the load coroutine when the composable first appears AND whenever transactionId changes. The previous coroutine is cancelled before the new one starts — preventing race conditions when navigating between transactions.
Spot the bug
@Composable
fun ProductListScreen(viewModel: ProductViewModel = hiltViewModel()) {
val products = viewModel.products.collectAsState() // Bug 1
var selectedCategory by mutableStateOf("All") // Bug 2
LazyColumn {
items(products.value) { product -> // Bug 3 — missing key
ProductCard(
product = product,
isSelected = product.id == viewModel.selectedId, // Bug 4
onClick = { viewModel.selectProduct(product.id) }
)
}
}
}
@Composable
fun ProductCard(product: Product, isSelected: Boolean, onClick: () -> Unit) {
val backgroundColor = if (isSelected) Color.Yellow else Color.White
Box(
modifier = Modifier
.background(backgroundColor)
.padding(16.dp) // Bug 5
.clickable(onClick = onClick)
.fillMaxWidth()
) {
Text(product.name)
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Jetpack Compose — Android Developers (Android Developers)
- State and Jetpack Compose (Android Developers)
- Compose performance — stability and recomposition (Android Developers)
- Thinking in Compose (Android Developers)
- Compose and other libraries — ViewModel, LiveData, Flow (Android Developers)