Lesson 18 of 83 intermediate

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

Jetpack Compose is like a smart whiteboard that redraws itself automatically when the data changes. With XML Views, you had to manually say 'erase name, write new name' every time data changed. With Compose, you describe WHAT the screen should look like given the current data — and the whiteboard figures out the minimum redraw needed. Recomposition is the whiteboard checking 'what changed?' and only updating those parts — not redrawing everything from scratch.

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

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. 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. 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. 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. 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. 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. 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. 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. 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?
Think about lifecycle-aware collection, state without remember, missing stable keys, reading ViewModel state in composables, and Modifier order.
Show answer
Bug 1: collectAsState() is not lifecycle-aware — it keeps collecting even when the app is in background, wasting CPU. Fix: collectAsStateWithLifecycle() from lifecycle-compose library. Bug 2: mutableStateOf() without remember — selectedCategory is reset to 'All' on every recomposition. Fix: var selectedCategory by remember { mutableStateOf('All') }. Bug 3: items(products.value) has no key parameter — Compose cannot track item identity across list updates. Adding/removing items causes incorrect animations and may reuse composable state for the wrong items. Fix: items(products.value, key = { it.id }). Bug 4: Reading viewModel.selectedId directly inside a composable — if selectedId is not a State/StateFlow, this won't trigger recomposition when it changes. If it IS a StateFlow, it must be collected via collectAsStateWithLifecycle(). Fix: val selectedId by viewModel.selectedId.collectAsStateWithLifecycle() and pass selectedId to the composable. Bug 5: Modifier order is wrong — background is applied before padding, so the padding area has NO background. Then clickable is after padding but before fillMaxWidth. Correct order: Modifier.fillMaxWidth().clickable(onClick).background(backgroundColor).padding(16.dp) — fill width first, then handle clicks on full area, then background covers click area, then content padding.

Explain like I'm 5

Jetpack Compose is like a magic coloring book. Instead of you coloring each picture manually (Android Views with setText, setImageBitmap, etc.), you write a recipe: 'if the number is 5, draw 5 stars.' Whenever the number changes, the book recolors itself automatically — but ONLY the pages with stars, not every page. remember is like writing your score in pencil on the page so it survives when you flip back. State hoisting is like the teacher keeping the master score sheet — individual students (composables) just read it and report changes back.

Fun fact

Jetpack Compose's recomposition can happen multiple times per second during animations. In the early betas, even a simple misconfiguration (like a lambda capturing unstable types) could trigger full-tree recomposition 60 times per second, completely negating any performance benefit. The Compose compiler plugin was updated to add @Composable function skipping analysis — it marks composables as skippable if all parameters are stable, and the runtime skips them if none changed.

Hands-on challenge

Build a TransactionFilterSheet in Compose: (1) A bottom sheet with date range picker (start/end date), min/max amount sliders, and a category multi-select. (2) Use state hoisting — FilterSheetContent is stateless (receives current FilterState and onFilterChange callback). (3) The parent FilterScreen holds a FilterState in a ViewModel StateFlow. (4) Use LazyColumn for the category list with key=category.id. (5) Add a @Preview showing the sheet in different filter states. (6) Write a composable UI test verifying that selecting a category calls onFilterChange with the correct FilterState.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery