01 The mental model
One paragraph, and everything else follows from it.
In CSS you write markup, then separately write rules that reach into that markup via selectors. The rules cascade; the browser figures out which wins.
In Compose, a UI is a Kotlin function. Calling the function builds the element.
Styling is done by passing arguments and chaining Modifier values on the element
itself. There's no global namespace, no specificity, no cascade. The "style" for a thing lives
exactly where the thing is written.
The one-liner: a <div> becomes a function call,
a CSS rule becomes an argument or a Modifier, and a class becomes another function.
| CSS world | Compose world |
|---|---|
| Element / tag | A @Composable function call |
| Class | A reusable @Composable fun |
| CSS rule | Function arguments + Modifier chain |
| Specificity / cascade | No CSS cascade; values come from arguments, modifier order, and composition locals |
| Stylesheet | A theme object (MaterialTheme) or Kotlin constants |
| CSS custom property | MaterialTheme.colorScheme / CompositionLocal |
| Re-render on state change | Automatic: reading state in a composable subscribes it |
| DevTools | Layout Inspector + @Preview in Android Studio |
Pseudo-classes (:hover) | State objects + Modifier.hoverable |
| Container queries | BoxWithConstraints |
CSS variables in :root | MaterialTheme.colorScheme |
| Hot reload | Live Edit in Android Studio / @Preview |
Units: forget px. Sizes are in dp (density-independent pixels,
not the same unit as CSS px, though layouts may use similar numeric values), and font sizes are in
sp (scales with the user's font-size setting). 16.dp, 14.sp.
02 Your first composable
The same card, written twice.
<div class="card">
<h2>Hello</h2>
<p>Body copy</p>
</div>
.card {
background: #161b22;
padding: 16px;
border-radius: 12px;
}
.card h2 {
font-size: 22px;
margin: 0 0 8px;
}
@Composable
fun ProfileCard() {
Column(
modifier = Modifier
.background(Color(0xFF161B22), RoundedCornerShape(12.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Hello",
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFFE6EDF3)
)
Text("Body copy", color = Color(0xFFC9D1D9))
}
}
Hello
Body copy
Things to notice:
Columnreplaced thedivand the default block flow at once.- The margin on
h2becameArrangement.spacedBy(8.dp)on the parent; Compose has no margin (more on that in §06). - Colours are
Color(0xFFRRGGBB):0xFFis the alpha channel, then hex, just like#RRGGBBwithFFin front. - Nothing is global. Rename the function and nothing else in the app can accidentally style it.
03 Live playground
Edit the code. The preview updates as you type. Everything below is rendered by a small Compose interpreter that runs in this page.
How to use it: pick a preset from the dropdown, tweak values, add children, change modifiers, anything. Press ⌘/Ctrl + Enter to force a re-render. If the code is invalid, the error shows up in the preview.
Supported in this playground: Column, Row, Box,
Text, Spacer, Divider; modifiers padding,
background, size, width, height,
fillMaxWidth, fillMaxHeight, weight, clip,
border, alpha, offset, rotate,
shadow, clickable; values dp, sp,
Color(0x…), RoundedCornerShape, CircleShape,
Arrangement.*, Alignment.*, FontWeight.*,
MaterialTheme.colorScheme.*.
Write the composable body directly; no @Composable fun Foo() { } wrapper needed
(though one is tolerated and unwrapped).
04 The cheat sheet
Bookmark this. Nine out of ten questions are answered here.
| What you want | CSS | Compose |
|---|---|---|
| Vertical stack | display:flex; flex-direction:column | Column { } |
| Horizontal stack | display:flex | Row { } |
| Overlay / stack on top | position:absolute | Box { }: later children draw on top |
| Gap between children | gap:12px | Arrangement.spacedBy(12.dp) |
| Main-axis align | justify-content | verticalArrangement (Column) / horizontalArrangement (Row) |
| Cross-axis align | align-items | horizontalAlignment (Column) / verticalAlignment (Row) |
| Fill remaining space | flex:1 | Modifier.weight(1f) |
| Full width | width:100% | Modifier.fillMaxWidth() |
| Fixed size | width:120px; height:48px | Modifier.size(120.dp, 48.dp) |
| Padding | padding | Modifier.padding(16.dp) |
| Margin | margin | Doesn't exist; use Arrangement.spacedBy or padding on the child |
| Border + radius | border, border-radius | Modifier.border(1.dp, color, RoundedCornerShape(12.dp)) |
| Clip children | overflow:hidden | Modifier.clip(RoundedCornerShape(12.dp)) |
| Background | background | Modifier.background(color, shape) |
| Gradient | linear-gradient(…) | Modifier.background(Brush.linearGradient(colors)) |
| Shadow | box-shadow | Modifier.shadow(8.dp, shape): elevation, not offsets |
| Opacity | opacity:.5 | Modifier.alpha(0.5f) |
| Rotate | transform:rotate(45deg) | Modifier.rotate(45f) |
| z-index | z-index | Declaration order in a Box, or Modifier.zIndex(1f) |
| Scrolling column | overflow-y:auto | LazyColumn or Modifier.verticalScroll(rememberScrollState()) |
| Pointer / click | cursor:pointer + listener | Modifier.clickable { } (includes ripple) |
| Font size / weight | font-size, font-weight | fontSize = 14.sp, fontWeight = FontWeight.Bold |
| Text alignment | text-align | textAlign = TextAlign.Center |
| Hover | :hover | Modifier.hoverable + collectIsHoveredAsState() |
| Media query | @media | BoxWithConstraints { maxWidth } |
| CSS variable | --brand | MaterialTheme.colorScheme.primary |
| Reusable class | .btn { } | Extract a @Composable fun |
| Disabled state | [disabled] | enabled = false argument |
| Focus ring | :focus | Modifier.focusable + FocusState |
| Cursor | cursor:pointer | Modifier.pointerHoverIcon(PointerIcon.Hand) |
| Sticky header | position:sticky | stickyHeader { } inside LazyColumn |
| Skeleton shimmer | CSS animation | rememberInfiniteTransition + animated brush |
05 Layout: Row, Column, Box
Three containers replace all of flexbox and most of positioning.
Column is flex-direction: column. Row is the default flex
direction. Box is a relative-positioned stacking context; children pile up, aligned
by contentAlignment.
.row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Total")
Text("42.00", fontWeight = FontWeight.Bold)
}
The naming rule is: arrangement is along the main axis (the direction the children flow), alignment is across it.
| CSS value | Compose |
|---|---|
flex-start | Arrangement.Start |
center | Arrangement.Center |
flex-end | Arrangement.End |
space-between | Arrangement.SpaceBetween |
space-around | Arrangement.SpaceAround |
space-evenly | Arrangement.SpaceEvenly |
gap: 12px | Arrangement.spacedBy(12.dp) |
Overlapping with Box
For position:absolute-style layering, use Box and Modifier.align:
Box(contentAlignment = Alignment.Center) {
Image(painter = avatar, contentDescription = null)
// Pinned to the top-right of the Box, like position:absolute; top:0; right:0
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(10.dp)
.background(Color(0xFF7EE787), CircleShape)
)
}
No position:relative needed. Box is already a positioning
context. Later children simply draw on top of earlier ones.
06 Spacing & the box model
Padding exists. Margin does not. This trips up everybody.
Compose has exactly one spacing concept on an element: padding, which sits inside
the element's own bounds (so background and click area include it). There is no
margin.
Three ways to get space between things
-
Siblings:
Arrangement.spacedBy(12.dp)on the parentRow/Column. This is the direct equivalent ofgapand covers most oldmarginuses. -
Space around one child: add
Modifier.padding(…)to that child. Visually it behaves like margin; just remember the padding is inside the child, so a background drawn before the padding will fill it. -
Space inside a container:
Modifier.padding(…)on the container itself.
Column(
modifier = Modifier.padding(24.dp), // space inside the container
verticalArrangement = Arrangement.spacedBy(16.dp) // gap between children
) {
Text("One")
Text("Two")
Text(
text = "Three",
modifier = Modifier.padding(top = 8.dp) // extra space around this one child
)
}
Padding shorthand is named arguments: Modifier.padding(horizontal = 16.dp,
vertical = 10.dp), or Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp,
bottom = 4.dp). Note start/end instead of
left/right; that's automatic RTL support, same idea as CSS logical
properties.
07 Modifiers, and why order matters
A modifier chain is not a rule block. It's a pipeline.
Modifier.background(…).padding(16.dp) reads left to right as "paint a background,
then shrink the content area inwards." Each call wraps the previous one. This is why the
order changes the result; unlike CSS, background and padding are
order-independent declarations.
The same rule applies to border, clip, clickable, and
shadow: whichever comes first in the chain defines the outer edge for everything
after it.
Rule of thumb: layout modifiers first (size, fillMaxWidth,
padding), then decoration (background, border,
clip, shadow), then interaction (clickable) last-ish.
If something "doesn't apply", check the order first.
Every composable should accept a modifier
This is the Compose equivalent of letting a parent class style a child. Convention: take
modifier: Modifier = Modifier as the first optional parameter and apply it to the
outermost element.
@Composable
fun Tag(
text: String,
modifier: Modifier = Modifier // caller can style/position it
) {
Text(
text = text,
modifier = modifier
.clip(RoundedCornerShape(50.dp))
.background(Color(0xFF1C2230))
.padding(horizontal = 10.dp, vertical = 4.dp),
fontSize = 12.sp
)
}
// Caller decides where it goes:
Tag("New", modifier = Modifier.align(Alignment.TopEnd))
08 Colour, type, shape
Design tokens live in a theme object: think :root custom properties.
Instead of --color-primary: #6C5CE7 in :root, you build a
MaterialTheme and read tokens off it anywhere in the tree. Changing the theme
re-renders everything that read from it, exactly like swapping a CSS variable.
@Composable
fun AppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = lightColorScheme(
primary = Color(0xFF6C5CE7),
background = Color(0xFFF8F9FB),
onSurface = Color(0xFF14161A)
),
typography = Typography(
titleLarge = TextStyle(fontSize = 22.sp, fontWeight = FontWeight.SemiBold),
bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 20.sp)
),
shapes = Shapes(
medium = RoundedCornerShape(12.dp)
),
content = content
)
}
Text(
text = "Subscribe",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surface
) {
// ...
}
Text does not inherit typography from its parent the way HTML text inherits
font-family. Each Text reads LocalTextStyle, which defaults
to bodyLarge. So set styles per Text, or override
LocalTextStyle for a subtree if you really want inheritance.
Spacing scale as tokens
Just like you'd define --space-1: 4px; --space-2: 8px; in CSS, define your spacing
scale in Kotlin and use it everywhere. This makes your UI feel consistent across screens without
any global stylesheet.
object Spacing {
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 24.dp
val xxl = 32.dp
}
// Then:
Column(
modifier = Modifier.padding(horizontal = Spacing.lg),
verticalArrangement = Arrangement.spacedBy(Spacing.sm)
) { /* ... */ }
09 State & interaction
There is no :hover, no classList.toggle. There's state.
In CSS + JS you flip a class and let CSS react. In Compose you hold a value in state; any
composable that reads it re-runs when it changes. No manual DOM updates, no
addEventListener.
@Composable
fun Counter() {
// 1. declare state 2. read/write it
var count by remember { mutableStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("$count", fontSize = 48.sp)
Button(onClick = { count++ }) {
Text("+1")
}
}
}
remember retains a value across recompositions while that call remains in the
composition. It is not tied to a DOM node, and the value can be recreated if the composable leaves
and later re-enters the composition. The by keyword is Kotlin property delegation; the
state example also needs the usual Compose runtime imports.
Here's what that counter actually does
The code above is real Compose. Below is a plain HTML mock of the same behaviour, so you can see how state drives the UI without a compiler:
A toggle with a hover-free interaction
@Composable
fun LikeButton() {
var liked by remember { mutableStateOf(false) }
val tint by animateColorAsState(
targetValue = if (liked) Color(0xFFFF5C8A)
else MaterialTheme.colorScheme.onSurfaceVariant,
label = "tint"
)
Row(
modifier = Modifier
.clip(RoundedCornerShape(50.dp))
.clickable { liked = !liked }
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Default.Favorite, contentDescription = null, tint = tint)
Text(if (liked) "Liked" else "Like", color = tint)
}
}
Notice the if. Because "styling" is just Kotlin, you can compute it with
expressions, loops, function calls, anything. No need for a parallel set of .is-active
modifier classes.
The three state tools you'll reach for
| Tool | What it's for | CSS-world equivalent |
|---|---|---|
remember { mutableStateOf(x) } | Local UI state that survives recomposition | A JS variable bound to a DOM element |
rememberSaveable | Same, plus survives configuration changes (rotation) | sessionStorage |
StateFlow / collectAsState | Observable state coming from a ViewModel | Redux/observable store |
derivedStateOf | Computed value from other state | CSS custom property derived from another |
rememberCoroutineScope | Launch async work from a click handler | async () => {} |
10 Lists
ul > li often maps to LazyColumn, which composes and lays out items as they are needed.
<ul class="messages">
<li class="message">…</li>
<li class="message">…</li>
</ul>
.messages {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
overflow-y: auto;
}
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(messages, key = { it.id }) { message ->
MessageRow(message)
}
}
contentPaddingis the list's internal padding (the equivalent of padding on a scroll container), and it doesn't clip the scroll area.keyis:nth-child-safety: give each row a stable id so Compose keeps the right element when the list changes.- Use a plain
Columnfor a handful of items;LazyColumnfor anything that scrolls.
// CSS: display:grid; grid-template-columns: repeat(2, 1fr)
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(photos) { PhotoTile(it) }
}
// CSS: display:flex; overflow-x:auto
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categories) { CategoryChip(it) }
}
11 Common patterns
Recipes you'll rebuild on every screen. Every one of these works in the playground.
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFFFFFFF), RoundedCornerShape(12.dp))
.padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.background(Color(0xFF6C5CE7), CircleShape),
contentAlignment = Alignment.Center
) {
Text("M", color = Color(0xFFFFFFFF), fontWeight = FontWeight.Bold)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Mia Chen", fontWeight = FontWeight.SemiBold)
Text("@miachen", fontSize = 13.sp, color = Color(0xFF5A6270))
}
}
Box(modifier = Modifier.size(56.dp)) {
Box(
modifier = Modifier
.size(56.dp)
.background(Color(0xFFF8F9FB), CircleShape)
)
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(14.dp)
.background(Color(0xFFFF5C8A), CircleShape)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("Morning run", fontWeight = FontWeight.SemiBold)
Text("3.2 km · 18 min", fontSize = 13.sp, color = Color(0xFF5A6270))
}
Text("→", color = Color(0xFF6C5CE7))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Design",
fontSize = 12.sp,
color = Color(0xFF6C5CE7),
modifier = Modifier
.clip(RoundedCornerShape(50.dp))
.background(Color(0xFFEEE9FF))
.padding(horizontal = 10.dp, vertical = 4.dp)
)
Text(
"Code",
fontSize = 12.sp,
color = Color(0xFF5A6270),
modifier = Modifier
.clip(RoundedCornerShape(50.dp))
.background(Color(0xFFF1F3F6))
.padding(horizontal = 10.dp, vertical = 4.dp)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("42", fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text("Runs", fontSize = 12.sp, color = Color(0xFF5A6270))
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("3.2", fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text("Km", fontSize = 12.sp, color = Color(0xFF5A6270))
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("18", fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text("Min", fontSize = 12.sp, color = Color(0xFF5A6270))
}
}
Column {
Text("One", modifier = Modifier.padding(16.dp))
Divider()
Text("Two", modifier = Modifier.padding(16.dp))
Divider()
Text("Three", modifier = Modifier.padding(16.dp))
}
Notice the pattern. Every recipe is a Column or Row plus a
couple of modifiers. There's no "utility class" layer: the layout is the component.
12 Responsive
@media becomes "ask the container how wide it is".
There are no media queries. Instead, BoxWithConstraints gives you the measured
maxWidth/maxHeight of the available space, and you branch in Kotlin.
@Composable
fun CardGrid(cards: List<Card>) {
BoxWithConstraints {
val columns = when {
maxWidth < 600.dp -> 1
maxWidth < 960.dp -> 2
else -> 3
}
LazyVerticalGrid(columns = GridCells.Fixed(columns)) {
items(cards) { card -> CardView(card) }
}
}
}
On Android you can also read the window size class
(calculateWindowSizeClass()) which is the closest thing to breakpoints, but
BoxWithConstraints is the container-query equivalent and often what you actually want.
Rule of thumb: reach for BoxWithConstraints first. It's the closest thing
Compose has to container queries, and it means your component responds to the space it's placed
in, not the whole screen.
13 Animation
transition: all .2s becomes an animated state value.
Instead of declaring a transition and changing a class, you animate a value and use it. Compose drives the value; anything that reads it updates.
.panel {
opacity: 0;
transform: translateY(8px);
transition: opacity .2s, transform .2s;
}
.panel.open {
opacity: 1;
transform: translateY(0);
}
val offset by animateDpAsState(
targetValue = if (open) 0.dp else 8.dp,
label = "offset"
)
val alpha by animateFloatAsState(
targetValue = if (open) 1f else 0f,
label = "alpha"
)
Modifier
.offset(y = offset)
.alpha(alpha)
For enter/exit of a whole subtree, use AnimatedVisibility(visible = open) { … }:
that's your display:none with a fade built in.
| CSS | Compose |
|---|---|
transition: opacity .2s | animateFloatAsState |
transition: transform .3s | animateDpAsState / animateFloatAsState |
transition: background .2s | animateColorAsState |
| Keyframe animation | Animatable + animateTo with keyframes |
| Spring easing | spring(dampingRatio, stiffness) |
| Enter / exit | AnimatedVisibility |
| List item changes | Modifier.animateItem() (Compose Foundation 1.7+) |
| Infinite pulse | rememberInfiniteTransition |
14 Traps for CSS brains
The five things that will bite you first.
1. There is no cascade
You cannot write a rule in a parent that restyles a child. If a child needs a variation, pass a
parameter or a Modifier. This feels like a loss for a week and then feels like
freedom forever.
2. There is no margin
Use Arrangement.spacedBy between siblings, or padding on the child. See §06.
3. Modifier order is semantic
.background().padding() ≠ .padding().background(). See §07.
4. Everything is Kotlin, so there's no "styles" layer
That's the upside. Extract a composable instead of a class, use constants instead of a variables file, and use a theme for the tokens that genuinely need to be global.
5. Don't do work inside a composable
A composable can run many times per second while animating. Run coroutine side effects in
LaunchedEffect { } and use DisposableEffect or SideEffect when appropriate.
remember { } caches a value across recompositions; it is not a side-effect handler.
Bonus: Text("Hello") doesn't inherit colour or font from a wrapping
Row. In HTML, text inherits; in Compose, it reads
LocalTextStyle / LocalContentColor, which default to the theme's
values, not the parent element's.
And one more: there's no :hover, no :focus, no
:active. Every interaction is state you hold and read. If you want a hover
effect, you write a boolean and animate it.
15 Kotlin essentials for UI work
Compose is Kotlin, so a few language features show up in almost every screen.
Values, functions, and null safety
val title: String = "Welcome" // read-only reference
var count: Int = 0 // reassignment allowed
val subtitle = "Hello, Compose" // type inferred
fun greeting(name: String): String = "Hello, $name"
// Nullable types must be handled deliberately.
val nickname: String? = null
val label = nickname ?: "Guest" // Elvis operator
// Safe call: length is Int? because nickname may be null.
val length = nickname?.length
Prefer val unless reassignment is genuinely needed. A type ending in ? can hold null; the compiler makes you handle that possibility rather than letting it surprise you at runtime.
Data classes and immutable UI models
data class Profile(
val name: String,
val role: String,
val isOnline: Boolean = false
)
@Composable
fun ProfileHeader(profile: Profile) {
Column {
Text(profile.name)
Text(profile.role)
if (profile.isOnline) Text("Online")
}
}
A composable should usually receive the data it needs as parameters. Keeping models immutable makes changes easier to reason about and helps you avoid hidden state.
Important: the snippets in this guide are teaching examples, not one file you can paste wholesale into a project. Real files need the appropriate imports, dependencies, and an enclosing Compose app.
16 Accessibility is part of the UI
A screen is not finished just because it looks right on your device.
- Use semantic components. Prefer
Button,Checkbox, and other Material components over clickable decorative containers when they fit the interaction. - Give icon-only actions a description. A screen reader cannot infer that a magnifying-glass icon means “Search”.
- Don't encode meaning with colour alone. Pair colour with text, an icon, or another clear signal.
- Respect text scaling. Use
spfor text and test with larger system font settings. Avoid fixed heights that clip text. - Check contrast and touch size. Controls should be easy to see and comfortably activate, including on smaller phones.
- Group related content sensibly. Use semantics only when needed, and avoid merging labels in ways that make navigation confusing.
IconButton(
onClick = onSearch,
modifier = Modifier.semantics {
contentDescription = "Search"
}
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null
)
}
For an icon inside a control that already has a semantic label, contentDescription = null prevents the icon from being announced redundantly. Import the relevant Material icon and semantics APIs in your project.
17 State and architecture that scale
Keep the UI declarative: state goes in, pixels come out, events go back up.
For a tiny demo, remember may be enough. For a real screen, separate the UI from data loading and business rules. A common Android pattern is a ViewModel exposing observable UI state, with the composable collecting it.
data class CounterUiState(val count: Int = 0)
class CounterViewModel : ViewModel() {
private val _uiState = MutableStateFlow(CounterUiState())
val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()
fun increment() {
_uiState.update { it.copy(count = it.count + 1) }
}
}
@Composable
fun CounterRoute(viewModel: CounterViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
CounterContent(count = state.count, onIncrement = viewModel::increment)
}
This is a pattern sketch, not a standalone compile-ready file: it assumes the Android lifecycle, ViewModel, and coroutine Flow dependencies and imports. In production, keep the state holder's lifetime appropriate to the screen or navigation destination.
- State hoisting: let a parent own state when multiple children need to share it. Pass the current value down and callbacks up.
- Events are not state: a text field's current value is state; “submit was tapped” is an event. Model each according to how long it needs to live.
- Don't launch work during composition: use event callbacks for user-triggered work and effect APIs for effects tied to composition.
- Keep state minimal: avoid storing values that can be derived reliably from other state.
18 Testing and debugging
A small feedback loop catches problems before they become a mysterious blank screen.
Useful checks while building
- Use
@Previewto inspect composables quickly, but also run the app on an emulator or device. A preview is not a replacement for runtime testing. - Use Compose UI tests to find nodes by text or semantics, perform actions, and assert what the user should see.
- Use Layout Inspector to inspect the actual hierarchy and modifiers when spacing or sizing behaves unexpectedly.
- Read the first meaningful compiler or Logcat error. Later errors are often consequences of the first one.
- Test narrow screens, landscape, larger text, dark theme, loading, empty, and error states, not only the happy path.
@Test
fun incrementButtonUpdatesCount() {
composeTestRule.setContent {
var count by remember { mutableIntStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
composeTestRule.onNodeWithText("Increment").performClick()
composeTestRule.onNodeWithText("Count: 1").assertExists()
}
The test sketch assumes a configured Compose test rule and the matching test dependencies. For app code, prefer testing the real screen or extracting logic into small units rather than testing only a duplicate toy implementation.
Debugging checklist: Is the state observable? Is it actually changing? Is the composable reading that state? Is a parent constraining the size? Is modifier order changing the result? Is the relevant code running on the expected screen?
19 Where next
You can't run Kotlin in a browser tab, so here's the fastest loop.
-
Install Android Studio, create an empty Compose activity, and put a
@Previewfunction above your composable. The preview pane updates when you refresh or use Live Edit;@Previewitself is not a general-purpose browser-style hot reload. - Keep the reference docs open: the Modifier list is the closest thing to a CSS property reference.
- Read Layout basics and State and Jetpack Compose next; together they cover 80% of day-to-day UI work.
- Compose Multiplatform runs the same code on desktop, iOS, and web, so the skills transfer beyond Android.
- The Component gallery is the closest thing to a design system reference: every Material 3 component is there with code samples.
Your unfair advantage: most Kotlin developers learn layout by trial and error. You already have a precise mental model of the box model, flex distribution, and design tokens. You mostly need to learn new nouns.