Compose & the Config Screen
~18 min read · Slice 3 of 10
Introduction
Slice 2 left us with a Start Workout button on screen, a basic understanding of what Gradle is doing under the hood, and a lifecycle log streaming through Logcat on every rotation. The button does nothing yet, and a single hardcoded composable isn't much of an app, so this slice is the dedicated Compose article, picking up the mental model in full, working through state, modifiers, and previews, and finishing with the workout configuration screen we'll need for the rest of the series.
Coming from React the conceptual side of Compose is gentler than the Kotlin side was in slice 1, since the architecture borrows heavily from React. Composables are functions that describe UI, they call other composables, and the framework figures out what to redraw when state changes. The friction is in the details, in how Compose handles state, how modifiers compose, and what the @Preview tooling actually previews, all of which are worth covering before we wire up an actual form.
The Compose mental model
A composable is a function annotated with @Composable that describes UI by calling other composables. It runs on the main thread, doesn't return a UI value, and gets re-run by the framework when the state it reads changes.
A composable function takes parameters, calls other composables, and that's it. There's no JSX-style template, no separate render method on a class, and no this-bound instance. The function body is the UI definition. Each time the state a composable reads changes, the framework re-runs that function, compares the result to the previous one, and updates only what actually changed on screen. The shape of that is the same idea as React's reconciliation, with the difference that Compose tends to be more aggressive about skipping work, since the compiler plugin tracks reads and writes and can prove that a particular composable doesn't need to run again.
1@Composable2fun Greeting(name: String) {3 Text("Hello, $name")4}
The PascalCase naming on composables isn't a style preference, it's enforced by the Compose compiler. Composables are meant to feel like a custom kind of UI element you're declaring, and the naming reflects that. If you write a composable with a lowercase name the lint check will flag it before you build.
| React | Compose |
|---|---|
| Function component | @Composable fun |
useState | remember { mutableStateOf(...) } |
| State persisted to localStorage | rememberSaveable |
value + onChange on a controlled input | value + onValueChange on a TextField |
| Lifting state up | State hoisting |
useMemo | derivedStateOf |
| className / style props | Modifier parameter |
| Storybook story | @Preview composable |
Layout primitives
Compose has no HTML, so the layout primitives come from the Material 3 library. The three you'll reach for in almost every screen are Column, Row, and Spacer, which between them cover most of what flexbox does on the web.
1@Composable2fun WorkoutSummary() {3 Column {4 Text("Boxercise")5 Text("3 rounds")6 Text("2 minute rounds")7 }8}
Column stacks children vertically, Row places them in a horizontal line, and Spacer is an empty composable you size with a modifier when you want to control the gap between children. There's no display: flex equivalent on individual elements, since the container itself is already a flex-style layout.
1@Composable2fun StatRow(label: String, value: String) {3 Row {4 Text(label)5 Spacer(modifier = Modifier.width(8.dp))6 Text(value)7 }8}
Coming from CSS the mental shift is that there's no separation between layout and content. A Column is both the markup and the layout rule, where on the web you'd write a <div> and then apply display: flex; flex-direction: column to it in a stylesheet. The proximity makes Compose layouts easier to read once you adjust, since there's only one file to look at instead of two.
Modifiers
A Modifier is a chain of decorations applied to a composable, including padding, size, click handlers, background colour, borders, and semantics. The chain composes left to right, and the order matters.
Every composable accepts a Modifier parameter as a convention, with the Modifier singleton as the default. You build a modifier by chaining functions onto it.
1@Composable2fun RoundBanner() {3 Text(4 text = "Round 1",5 modifier = Modifier6 .fillMaxWidth()7 .padding(16.dp)8 .background(Color.LightGray),9 )10}
The bit that catches every newcomer is that the order of the chain changes the result. .padding(16.dp).background(Red) and .background(Red).padding(16.dp) look like they should produce the same thing, but they don't, because each modifier wraps the next one. The padding either sits inside the red background or outside it depending on the order.
1// padding then background:2// the padding sits outside the red area3Modifier4 .padding(16.dp)5 .background(Color.Red)
1// background then padding:2// the padding sits inside the red area3Modifier4 .background(Color.Red)5 .padding(16.dp)
| Chain | Result |
|---|---|
.padding(16).background(Red) | The padding is outside the red area, so the red rectangle is smaller than the box |
.background(Red).padding(16) | The padding is inside the red area, so the red rectangle fills the box and the content is inset |
The convention worth picking up early is that every composable you write should accept a Modifier parameter and forward it to the root composable inside, which lets the caller customise spacing, alignment, and click behaviour without you having to expose every option as a separate parameter. It's the closest equivalent to the className-and-style props pattern in React, with the difference that the modifier is type-safe and not opaque, so you know exactly which decorations the parent can apply.
State with remember and mutableStateOf
State in Compose is held in observable containers. The simplest is mutableStateOf, which holds a single value and notifies any composable that reads it to re-run whenever the value changes. A counter is the canonical first example.
1@Composable2fun Counter() {3 var count by remember { mutableStateOf(0) }4 Button(onClick = { count++ }) {5 Text("Count: $count")6 }7}
There are two things working together here. remember holds onto the value across recompositions, so when the function re-runs the count doesn't get reset to zero on every pass. The mutableStateOf call creates the observable container that triggers a recomposition when its value changes. The by delegation is Kotlin's property delegation syntax, which lets you read and write count as if it were a plain Int while the actual storage lives on the state object.
Without remember the count would reset on every recomposition, since the value would be created fresh each time the function ran. Without mutableStateOf, changing the value wouldn't trigger a recomposition, since plain Kotlin values don't notify anyone. The two together are the simplest way to hold mutable state in a composable.
remember stores a value across recompositions of the composable that called it. The storage is keyed on the composable's position in the call tree, which is how Compose finds the same slot on each run.
State hoisting
The pattern that's near-identical to React is state hoisting. When two siblings need to know about the same value, or when you want a child composable to be reusable in different contexts, you lift the state to a parent and pass it down alongside a callback that lets the children request changes.
1@Composable2fun Counter(count: Int, onIncrement: () -> Unit) {3 Button(onClick = onIncrement) {4 Text("Count: $count")5 }6}78@Composable9fun CounterParent() {10 var count by remember { mutableStateOf(0) }11 Counter(count = count, onIncrement = { count++ })12}
In React this is the controlled input pattern: the parent owns the state, the child receives it through props, and the child reports changes through onChange. Compose uses the same shape, with value and onValueChange parameters where React would have value and onChange. The natural result is that small leaf composables stay stateless and a smaller number of higher-up composables hold the state, which tends to keep the tree easy to test in isolation.
rememberSaveable
remember holds state across recompositions but not across configuration changes, since slice 2 showed us the activity gets destroyed and recreated on rotation, which means composables in the tree are rebuilt from scratch on the new instance. To survive that, swap remember for rememberSaveable, which serialises its value to the activity's saved-state bundle and restores it on the next instance.
1@Composable2fun Counter() {3 var count by rememberSaveable { mutableStateOf(0) }4 Button(onClick = { count++ }) {5 Text("Count: $count")6 }7}
rememberSaveable only works for types the saved-state bundle can serialise, which includes the primitive types, String, Parcelable, and a few standard collections. For more complex objects you can supply a custom Saver, but you'll rarely need to, since most screen-local state fits the supported types.
| API | Survives recomposition? | Survives rotation? |
|---|---|---|
Plain var in composable | No | No |
remember { mutableStateOf(...) } | Yes | No |
rememberSaveable { mutableStateOf(...) } | Yes | Yes (for supported types) |
ViewModel (slice 4) | Yes | Yes (and across navigation) |
The pairing back to slice 2's lifecycle section is direct. Rotation destroys and recreates the activity, and rememberSaveable is the cheapest tool for keeping a single screen's state across the recreation. ViewModel is what you reach for once state is shared across screens or needs to outlive the composition, which is the work slice 4 picks up.
Number inputs with OutlinedTextField
Material 3 provides OutlinedTextField, the standard input component. It's a stateless composable that follows the hoisted-state pattern, taking a value and an onValueChange callback. The simplest usage is a single-line text input.
1@Composable2fun NameField() {3 var name by rememberSaveable { mutableStateOf("") }4 OutlinedTextField(5 value = name,6 onValueChange = { name = it },7 label = { Text("Name") },8 )9}
For numeric input we set keyboardOptions to tell the OS to show the number keypad, and add a small bit of validation in the onValueChange callback to filter non-digit input on the way in.
1@Composable2fun NumberField(3 label: String,4 value: String,5 onValueChange: (String) -> Unit,6 modifier: Modifier = Modifier,7) {8 OutlinedTextField(9 value = value,10 onValueChange = { input ->11 if (input.all { it.isDigit() }) {12 onValueChange(input)13 }14 },15 label = { Text(label) },16 keyboardOptions = KeyboardOptions(17 keyboardType = KeyboardType.Number,18 ),19 singleLine = true,20 modifier = modifier,21 )22}
The value is held as a String rather than an Int, since the user's intermediate input might not be a valid number, and string-as-state lets the field display partial entries cleanly. Converting to an Int happens at the edges of the form, typically when the user submits, which is the same pattern you'd use for a numeric input in React.
The Config Screen
With the pieces in place we can put the workout configuration screen together. The requirements from the slice description are three number inputs for rounds, round length, and rest length, and a Start button that consumes the values. We'll also display the total length of the workout, computed on the fly as the user types.
1@Composable2fun ConfigScreen(modifier: Modifier = Modifier) {3 var rounds by rememberSaveable { mutableStateOf("3") }4 var roundSeconds by rememberSaveable { mutableStateOf("120") }5 var restSeconds by rememberSaveable { mutableStateOf("60") }67 val totalSeconds by remember {8 derivedStateOf {9 val r = rounds.toIntOrNull() ?: 010 val rd = roundSeconds.toIntOrNull() ?: 011 val rs = restSeconds.toIntOrNull() ?: 012 r * rd + (r - 1).coerceAtLeast(0) * rs13 }14 }1516 val isValid by remember {17 derivedStateOf {18 (rounds.toIntOrNull() ?: 0) > 0 &&19 (roundSeconds.toIntOrNull() ?: 0) > 0 &&20 restSeconds.toIntOrNull() != null21 }22 }2324 Column(25 modifier = modifier26 .fillMaxSize()27 .padding(16.dp),28 verticalArrangement = Arrangement.spacedBy(16.dp),29 ) {30 Text(31 text = "Configure your workout",32 style = MaterialTheme.typography.headlineSmall,33 )3435 NumberField(36 label = "Rounds",37 value = rounds,38 onValueChange = { rounds = it },39 modifier = Modifier.fillMaxWidth(),40 )4142 NumberField(43 label = "Round length (seconds)",44 value = roundSeconds,45 onValueChange = { roundSeconds = it },46 modifier = Modifier.fillMaxWidth(),47 )4849 NumberField(50 label = "Rest length (seconds)",51 value = restSeconds,52 onValueChange = { restSeconds = it },53 modifier = Modifier.fillMaxWidth(),54 )5556 Text(57 text = "Total: ${totalSeconds}s",58 style = MaterialTheme.typography.bodyLarge,59 )6061 Button(62 onClick = { /* slice 4 wires this up to a workout state */ },63 enabled = isValid,64 modifier = Modifier.fillMaxWidth(),65 ) {66 Text("Start Workout")67 }68 }69}
There are a few choices in this composable worth pointing out:
- The state is hoisted into the screen-level composable, not into the input fields. The fields are stateless and stay simple, which makes them easy to drop into a preview or a test.
- rememberSaveable is used for each field, so a rotation in the middle of editing doesn't wipe the user's work.
- A derivedStateOf computes the workout's total length on the fly. Compose re-evaluates it only when one of the inputs it reads changes, the same way useMemo would in React, but with automatic dependency tracking.
- The Start button's enabled state is itself derived from the field values, so the button greys out whenever the form is incomplete or invalid without us having to manage a separate flag.
- Arrangement.spacedBy(16.dp) on the column gives consistent vertical gaps between every child, which is tidier than peppering individual Spacer calls between siblings.
In slice 4 the same screen will move its state into a ViewModel, since at that point we'll want the config to survive backgrounding and we'll want to test the validation logic without spinning up a UI. For slice 3 keeping everything inside the composable keeps the focus on Compose itself rather than on architecture.
Previews
@Preview is the annotation that drives Android Studio's preview pane, letting you see a composable render without building and running the app. The pane sits next to the editor and updates on save, which makes iterating on UI roughly as quick as hot reload feels in the web world.
1@Preview(showBackground = true)2@Composable3fun ConfigScreenPreview() {4 BoxerciseTheme {5 ConfigScreen()6 }7}
You can stack previews to render the same composable in multiple configurations. The pattern I reach for first on a new screen is light and dark, then a smaller-screen variant for sanity-checking layout on a constrained width.
1@Preview(name = "Light", showBackground = true)2@Preview(3 name = "Dark",4 showBackground = true,5 uiMode = UI_MODE_NIGHT_YES,6)7@Composable8fun ConfigScreenPreview() {9 BoxerciseTheme {10 ConfigScreen()11 }12}
Previews are evaluated like real composables, so any state you put in them is real state, which is useful for showing different content states without having to wire up a parameterised mock. For the config screen, a preview that opens with sensible defaults shows you the typical case instantly, and a second preview seeded with empty strings shows you the disabled-button state. Both are useful when you're tweaking the design.
Recomposition, briefly
The bit that makes Compose feel fast is that the compiler tracks which state each composable reads, and the framework only re-runs the composables whose inputs have changed. This is called recomposition, and it's the analogue of React's render-and-diff cycle, but with finer granularity. A change to a single field in a data class causes a recomposition of whichever composables read that field, not of every composable in the tree.
For this to work, Compose needs to be able to tell whether two values are equal cheaply. The compiler infers stability for most types automatically, including primitives, String, and immutable data classes with stable fields, but mutable types and types from third-party libraries are sometimes flagged as unstable. The compiler treats unstable types defensively, recomposing more broadly than it strictly needs to. The fix when you hit a hot path is usually to add the @Stable or @Immutable annotation to the data class, or to wrap an external type in your own stable holder.
1@Immutable2data class WorkoutConfig(3 val rounds: Int,4 val roundSeconds: Int,5 val restSeconds: Int,6)
The config screen doesn't have a stability issue, since its state is all primitives and strings, but it's worth knowing the lever exists for when an app's recomposition profile gets noisy. Layout Inspector with the recomposition count overlay turned on, available from View > Tool Windows > Layout Inspector, is the tool you reach for to investigate.
Recap
We covered what a composable is, the layout primitives, how modifiers chain, and the three forms of state Compose recognises, with the React parallel for each one alongside. The config screen pulled all of it together into a small but realistic form, with derived state for the total and a validation-driven button. Previews gave us a way to iterate on the UI without launching the app, and the recomposition section flagged the stability lever for when an app grows past the point where the defaults are enough.
Further reading
The Compose documentation is one of the strongest parts of the Android developer site. These are the pages worth bookmarking for the bits we glossed over.
- Thinking in Compose: the framework's own framing of the declarative model. Worth reading once you've felt the basics in your own code.
- State and Jetpack Compose: the canonical reference for remember, rememberSaveable, derivedStateOf, and the saved-state Saver API.
- Compose Modifiers: the full list of built-in modifiers, with the ordering rules covered in more depth.
- Text fields in Compose: the patterns for password fields, error states, prefix and suffix decorations, and accessibility hooks beyond the basic example here.
- Compose previews: parameterised previews, device specs, interactive previews, and the full preview annotation reference.
- Stability in Compose: when and how to annotate types as @Stable or @Immutable, and how to read the compiler's stability report.
- Material 3 for Jetpack Compose: the component catalogue, with usage guidance for buttons, cards, dialogs, navigation, and more.
Try this for yourself
A small extension to the config screen that makes the state-hoisting and derived-state ideas stick. Add an OutlinedTextField for the user's name above the existing inputs, and update the title from "Configure your workout" to "Configure your workout, $name" using string interpolation. The challenge is in keeping the input controlled and saveable across rotation, with the title updating live as the user types.
Show one way to write it
1var name by rememberSaveable { mutableStateOf("") }23OutlinedTextField(4 value = name,5 onValueChange = { name = it },6 label = { Text("Your name") },7 singleLine = true,8 modifier = Modifier.fillMaxWidth(),9)1011Text(12 text = if (name.isBlank()) {13 "Configure your workout"14 } else {15 "Configure your workout, $name"16 },17 style = MaterialTheme.typography.headlineSmall,18)
The title is a plain Text that reads the name state directly, so Compose recomposes it on every keystroke automatically, no manual wiring required.
Slice 4 takes the config screen and moves its state into aViewModel, which is the architecture component Android uses to outlive both recomposition and configuration change. The ViewModel also gives us a clean place to put the validation logic so it can be unit tested without spinning up a UI, which is where the series starts to feel like building an actual app rather than a tour.