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 `ListLazyColumn 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: ListSide 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 { mutableStateOfMaterial 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
About the author. Blake Crosley founded ResumeGeni and writes about Android engineering, hiring technology, and ATS optimization. More writing at blakecrosley.com.