Android Engineer Hub

Jetpack Compose and Material 3: The Senior Android UI Toolkit

In short

Jetpack Compose is the declarative UI toolkit that has fully replaced the View system for new Android development. Senior engineers are expected to think in composables, recomposition, and hoisted state instead of XML layouts and findViewById. This deep-skill covers the Compose mental model (composable functions, recomposition, state hoisting, `remember`), the state APIs (`mutableStateOf`, `rememberSaveable`, `derivedStateOf`), the LazyColumn / LazyRow performance contract (`key`, `contentType`, stable items), the side-effect primitives (`LaunchedEffect`, `DisposableEffect`, `produceState`), and Material 3 (theming, dynamic color, M3 components, accessibility). It closes with a brief note on Compose Multiplatform —

Key takeaways

  • A composable describes UI as a function of state; recomposition re-runs it when state changes, so the body must stay pure.
  • Hoist state: stateful composables own `remember { mutableStateOf }`, stateless ones receive value + onValueChange — the second form is reusable and testable.
  • `remember` survives recomposition; `rememberSaveable` survives configuration changes and process death — use it for anything the user would be annoyed to lose.
  • On `LazyColumn`, always supply `key` (stable identity) and `contentType` (composition pooling) — without them, scrolling is slow and item state collapses.
  • Item types must be Stable to skip recomposition: prefer `ImmutableList` over `List`, and only use `@Stable` / `@Immutable` when the promise is real.
  • Side effects belong in `LaunchedEffect` (coroutines tied to keys), `DisposableEffect` (listeners with explicit teardown), or `rememberCoroutineScope` (callback-driven launches).
  • `derivedStateOf` memoizes derived values so readers recompose only on threshold crossings, not on every input change.
  • Material 3: theme through `MaterialTheme.colorScheme`, support `dynamicColorScheme` on Android 12+, and never hardcode raw colors in composables.
  • Accessibility is non-optional: 48 dp touch targets, `contentDescription` on every interactive node, and contrast verified for any custom color.
  • Compose Multiplatform extends the same model to iOS, desktop, and web — your Compose investment is portable.

Compose mental model: recomposition + state hoisting

A composable function is a Kotlin function annotated `@Composable` that describes UI in terms of its inputs. It does not return a View — it emits into a slot in the composition tree. When any state it reads changes, Compose schedules a **recomposition**: the function runs again with the new inputs and the runtime diffs the emitted nodes against the previous tree. Recomposition is the single most important concept in Compose; everything else (state hoisting, `remember`, stability) is a tactic for making recomposition correct and cheap. Two rules govern recomposition. First, **a composable can re-run at any time, in any order, possibly in parallel** — so it must be free of side effects. Mutating a list, hitting the network, or writing to disk inside the body of a composable is a bug. Side effects belong inside `LaunchedEffect`, `DisposableEffect`, or `rememberCoroutineScope`. Second, **recomposition is skippable when inputs are stable and unchanged**. The Compose compiler tags every parameter type as Stable or Unstable; if all inputs are stable and equal to last frame, the composable is skipped. Unstable inputs (a `List` from a non-immutable source, a lambda capturing mutable state) force recomposition every frame. **State hoisting** is the discipline that makes composables reusable and testable. The pattern: a stateful composable owns `remember { mutableStateOf }` internally; a stateless composable accepts the value and an `onValueChange` lambda from its parent. Hoist state up to the lowest common ancestor that needs it — usually a screen-level composable or a ViewModel. ```kotlin // Stateful: owns its state, hard to control from a parent @Composable fun SearchBarStateful() { var query by rememberSaveable { mutableStateOf("") } TextField(value = query, onValueChange = { query = it }) } // Stateless: state is hoisted, fully reusable and testable @Composable fun SearchBar(query: String, onQueryChange: (String) -> Unit) { TextField(value = query, onValueChange = onQueryChange) } ``` `remember` is the bridge between an imperative world and Compose's functional one. `remember { mutableStateOf("") }` allocates the state exactly once for the lifetime of that call site in the composition; on recomposition, Compose returns the same instance. `rememberSaveable` additionally survives configuration changes and process death by writing to the saved-instance bundle — use it for anything the user would be annoyed to lose on rotation (form input, scroll position, expanded/collapsed flags). A useful interview heuristic: the *only* legal way to read mutable state from a composable is through a `State` object (`mutableStateOf`, `StateFlow` via `collectAsStateWithLifecycle`, `rememberSaveable`, or a `derivedStateOf`). Anything else — a plain `var`, a static singleton, a captured `MutableList` — will not trigger recomposition when it changes, and you will spend an afternoon wondering why your UI is stale.

LazyColumn performance: keys, contentType, stable items

`LazyColumn` and `LazyRow` are Compose's equivalent of RecyclerView — they compose only the items currently visible plus a small viewport buffer. The DSL is deceptively simple, which is exactly why senior engineers find performance bugs in juniors' code. Three knobs separate a list that scrolls at 120 fps from one that drops frames on a Pixel 4: **`key`**, **`contentType`**, and **stable item types**. By default, `LazyColumn` keys items by their position. When you insert a row at the top, every item below it is treated as a new key — Compose tears down and rebuilds every visible row, including their `remember`ed state. That is where 'my expanded card collapses on scroll' bugs come from. The fix is to supply a stable key derived from the data: typically the item's primary key, never its index. ```kotlin @Composable fun JobList(jobs: List, onClick: (String) -> Unit) { LazyColumn( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items( items = jobs, key = { it.id }, // identity survives reordering contentType = { it.type }, // pool composables by type ) { job -> JobCard(job = job, onClick = { onClick(job.id) }) } } } ``` `contentType` is the second multiplier. Compose maintains an internal pool of ready-made compositions for reuse as items scroll past. With no `contentType` everything goes into one bucket, and a 'header' row recomposing as a 'job card' wastes the entire pool. Tagging items by visual type ('header', 'job', 'ad', 'footer') lets Compose recycle compositions of the same shape. On long heterogeneous lists this is the difference between smooth scrolling and visible jank. The third lever is **stability of the item type itself**. If `Job` is a regular `data class` whose fields are all stable (primitives, `String`, other stable data classes), the Compose compiler will mark it Stable and skippable. If it contains a `List` from `kotlin.collections.List` (which is technically not immutable), the compiler downgrades the whole class to Unstable and every `JobCard` recomposes on every parent recomposition. The fix is `kotlinx.collections.immutable.ImmutableList` (or `PersistentList`), which the compiler recognizes as truly immutable. Annotating with `@Immutable` or `@Stable` is also valid, but only if the promise is real — lying to the compiler creates 'why isn't my UI updating' bugs that survive PR review. Two more habits. **Pass lambdas as method references or remember them**: `onClick = { onClick(job.id) }` allocates a new lambda every recomposition and breaks skipping; remember it once or pass `onClick` directly with the id bound at the call site. **Use `LazyVerticalStaggeredGrid` and `LazyVerticalGrid` for grids** rather than nesting `LazyColumn` inside `LazyRow` — nested lazy layouts have bounded-measurement constraints that are easy to violate. Profile with the Layout Inspector's recomposition counts; a healthy item shows 1 composition and 0 recompositions during steady scroll.

Side effects: LaunchedEffect / DisposableEffect / derivedStateOf

A composable body must be pure — it describes UI as a function of state. Anything that reaches outside that contract (network calls, animations, registering a listener, logging an analytics event) is a **side effect** and belongs in one of Compose's effect APIs. Each one solves a different shape of problem; senior engineers reach for the right one without thinking. **`LaunchedEffect(key1, key2, ...)`** runs a coroutine block when the composable enters the composition and re-runs it whenever any key changes. Use it for one-shot work tied to inputs: kicking off a network fetch when a screen opens, restarting a timer when an id changes, observing a Flow you did not get from `collectAsStateWithLifecycle`. The keys are how you express 'restart this effect when these inputs change' — pick keys deliberately, because `LaunchedEffect(Unit)` runs once and never restarts, while `LaunchedEffect(searchQuery)` restarts on every keystroke. ```kotlin @Composable fun JobDetailScreen(jobId: String, repo: JobsRepository) { var job by remember { mutableStateOf(null) } LaunchedEffect(jobId) { // restarts when jobId changes job = null // optimistic clear job = repo.fetchJob(jobId) // suspend; cancelled if jobId changes } DisposableEffect(jobId) { val sub = repo.subscribeApplicants(jobId) { /* ... */ } onDispose { sub.cancel() } // teardown on leave / key change } job?.let { JobDetail(it) } ?: LoadingIndicator() } ``` **`DisposableEffect(key)`** is for non-coroutine resources that need explicit teardown — `BroadcastReceiver` registration, sensor listeners, Choreographer callbacks, third-party SDK observers. The mandatory `onDispose { }` block runs when the composable leaves the composition or when the key changes. Forgetting to call dispose is a memory leak Compose cannot rescue you from. **`derivedStateOf { }`** is the optimization primitive most commonly missed. It memoizes a derived value and only triggers recomposition of *its* readers when the derived result actually changes. The classic example: 'show a scroll-to-top button when the list is past item 0.' Reading `listState.firstVisibleItemIndex > 0` directly will recompose the button on every pixel of scroll. Wrapping it in `derivedStateOf` recomposes only on the boundary crossings — once when scrolling past item 0, once when returning. **`rememberCoroutineScope()`** is the imperative escape hatch: it returns a `CoroutineScope` tied to the composition, suitable for launching work in response to user events (`onClick = { scope.launch { ... } }`). **`produceState`** wraps a `LaunchedEffect` plus a `mutableStateOf` into one call when you want to expose the result as a `State`. The decision tree: do you need to start a coroutine when inputs change? `LaunchedEffect`. Do you need to register and unregister a listener? `DisposableEffect`. Do you need to derive a value from other state and avoid thrashing? `derivedStateOf`. Do you need to launch a coroutine from a callback? `rememberCoroutineScope`. Picking the wrong one is rarely catastrophic, but it is exactly the kind of detail PR reviewers flag, and the patterns compound across a codebase.

Material 3: theming, dynamic color, accessibility

Material 3 (codename 'Material You') is the design system every modern Android app should target. It supplies a `ColorScheme` of 30+ semantic tokens (`primary`, `onPrimary`, `surface`, `surfaceVariant`, `error`, etc.), a `Typography` of 15 named text styles, and a `Shapes` set of three shape categories (small, medium, large). The senior-engineer move is to **never reference raw colors in composables** — always go through `MaterialTheme.colorScheme.primary` or a custom token, so light/dark/dynamic all flow correctly. On Android 12+, Material 3 supports **dynamic color** — the color scheme is extracted from the user's wallpaper at runtime via `dynamicLightColorScheme(context)` / `dynamicDarkColorScheme(context)`. This is what makes Material You feel personal. Below Android 12, you fall back to a fixed `lightColorScheme()` / `darkColorScheme()` defined from your brand. A correct theme function handles all four cases (dynamic light, dynamic dark, static light, static dark) in one place. ```kotlin @Composable fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit, ) { val context = LocalContext.current val colors = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) darkTheme -> DarkColors else -> LightColors } MaterialTheme( colorScheme = colors, typography = AppTypography, shapes = AppShapes, content = content, ) } ``` Pick **M3 components** (`androidx.compose.material3.*`), not the legacy M2 ones — `Button`, `Card`, `TopAppBar`, `NavigationBar`, `Scaffold`, `ModalBottomSheet`, `SearchBar`. They ship with correct elevation tonal overlays, ripple, and motion specs. Use `Scaffold` as the screen-level frame; it slots a top app bar, bottom bar, FAB, and snackbar host with correct insets and content padding. Never hardcode status-bar height — use `WindowInsets.systemBars` and `Modifier.windowInsetsPadding(...)`. Edge-to-edge is now the default on Android 15+ and handled correctly only by code that respects insets. Accessibility is part of M3, not an extra. Every interactive composable must have a `contentDescription` or `Modifier.semantics { contentDescription = ... }`; decorative images should pass `contentDescription = null`. Touch targets must be at least 48 dp — `Modifier.minimumInteractiveComponentSize()` enforces it. Colors must clear WCAG contrast (the M3 token pairs already satisfy 4.5:1 for normal text and 3:1 for large), but the moment you introduce a custom `Color(0xFFAABBCC)` you need to verify with the Accessibility Scanner. TalkBack ordering follows the composable tree by default; group related elements with `Modifier.semantics(mergeDescendants = true)` so 'Save 50%' and the price read as one item rather than three. A note on **Compose Multiplatform**: the same APIs (`@Composable`, `Modifier`, `LazyColumn`, even Material 3 via `compose-multiplatform`) now compile to iOS, desktop, and web. JetBrains ships it; companies like Cash App and McDonald's run real production UI on it. For Android specialists this means your Compose skills are no longer locked to Android — and shared UI is becoming a credible alternative to a SwiftUI rewrite when an iOS codebase is on the roadmap. Chris Banes and the Now in Android sample are the canonical references for keeping an app idiomatic as the surface expands.

Frequently asked questions

Common pitfall
Calling network, IO, or analytics directly inside a composable body — recomposition will fire it dozens of times per second.
Common pitfall
Using item index as the `LazyColumn` key, then watching expanded-card and scroll state collapse on insertions.
Common pitfall
Forgetting `contentType` on heterogeneous lists, defeating Compose's composition pool and producing visible jank.
Common pitfall
Passing a `kotlin.collections.List` into a composable parameter, downgrading the whole class to Unstable and recomposing on every parent change.
Common pitfall
Reading scroll position directly (`firstVisibleItemIndex > 0`) instead of through `derivedStateOf`, causing button recompositions on every pixel.
Common pitfall
Using `LaunchedEffect(Unit)` when the work depends on an input — it runs once and never restarts when the input changes.
Common pitfall
Skipping `onDispose` in `DisposableEffect` and leaking listeners across navigation.
Common pitfall
Hardcoding `Color(0xFF...)` in components instead of going through `MaterialTheme.colorScheme`, breaking dark mode and dynamic color.

Sources

  1. Jetpack Compose
  2. Lists and grids in Compose
  3. Side-effects in Compose
  4. Compose performance
  5. Material 3 design system
  6. Chris Banes — Compose patterns and Android engineering

About the author. Blake Crosley founded ResumeGeni and writes about Android engineering, hiring technology, and ATS optimization. More writing at blakecrosley.com.