Skip to main content

Command Palette

Search for a command to run...

Building Jetpack Compose Components the Right Way

Updated
•6 min read•View as Markdown
Building Jetpack Compose Components the Right Way

If you've spent any time in a Compose codebase past the "hello world" stage, you've probably run into a composable that does everything: fetches data, holds business logic, manages its own state, and renders five different UI variations depending on a dozen boolean flags. It compiles. It even works. But nobody wants to touch it, and every new feature request makes it worse.

After years building large, multi-module Android apps, I've found the difference between a Compose codebase that scales and one that turns into spaghetti almost always comes down to a handful of component design habits. Here's what actually holds up in production.

1. Stateless composables are your default, not your exception

The single highest-leverage habit in Compose is state hoisting: a composable shouldn't own state it doesn't need to own. Push state up to the caller, and let the composable just describe how to render it.

// Avoid: the composable owns state it shouldn't
@Composable
fun SearchBar() {
    var query by remember { mutableStateOf("") }
    TextField(value = query, onValueChange = { query = it })
}
 
// Prefer: state is hoisted, composable is stateless
@Composable
fun SearchBar(
    query: String,
    onQueryChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    TextField(value = query, onValueChange = onQueryChange, modifier = modifier)
}

The stateless version is trivially testable, previewable with any input, and reusable anywhere — a settings screen, a dialog, a full-screen search — without dragging along assumptions about where its state lives. Keep a thin stateful wrapper at the screen level if you need one, and let everything below it be dumb and predictable.

2. Design with slots, not booleans

A common anti-pattern is a component that grows a new Boolean or nullable parameter every time a designer asks for a variant:

@Composable
fun InfoCard(
    title: String,
    showIcon: Boolean,
    iconRes: Int? = null,
    showAction: Boolean = false,
    actionText: String? = null,
    onActionClick: (() -> Unit)? = null
)

Six months in, this function has fifteen parameters and half of them are mutually exclusive in ways nobody's documented. A slot API — composable lambda parameters instead of flags — scales much better:

@Composable
fun InfoCard(
    modifier: Modifier = Modifier,
    leadingIcon: @Composable (() -> Unit)? = null,
    title: @Composable () -> Unit,
    trailingAction: @Composable (() -> Unit)? = null
) {
    Card(modifier) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            leadingIcon?.invoke()
            Box(Modifier.weight(1f)) { title() }
            trailingAction?.invoke()
        }
    }
}

Now callers compose whatever content they need — an icon, a badge, a button, nothing — without the component itself needing to know what "action" or "icon" means. This is the same pattern Scaffold, TopAppBar, and Card use internally, and it's worth copying.

3. Every composable should accept — and pass through — a Modifier

This one's simple but gets skipped constantly: every public composable should take a Modifier = Modifier parameter, apply it to its outermost layout node, and not have a default that fights the caller (like a hardcoded padding the caller can't remove). Skipping this is the single biggest reason people end up wrapping your component in an extra Box just to add margin.

@Composable
fun UserAvatar(
    imageUrl: String,
    modifier: Modifier = Modifier // always present, always applied first
) {
    AsyncImage(
        model = imageUrl,
        contentDescription = null,
        modifier = modifier.clip(CircleShape)
    )
}

4. Keep composables small enough to preview meaningfully

If you can't write a useful @Preview for a composable without mocking half your app's dependencies, it's doing too much. A good litmus test: can you preview it with hardcoded sample data and no ViewModel, repository, or navigation controller in sight?

@Preview
@Composable
private fun InfoCardPreview() {
    InfoCard(
        leadingIcon = { Icon(Icons.Default.Info, null) },
        title = { Text("Battery optimization available") },
        trailingAction = { TextButton(onClick = {}) { Text("View") } }
    )
}

Composables that need real data sources to render are composables that need to be split — pull the data-fetching and business logic up into a stateful wrapper, and leave the presentation piece pure.

5. Avoid duplicate sources of truth

A subtle bug pattern: a composable receives a value via parameter, then also keeps a local remember copy of "the same" value, and the two drift out of sync after a recomposition or process death.

// Bug waiting to happen
@Composable
fun VolumeSlider(initialVolume: Float) {
    var volume by remember { mutableStateOf(initialVolume) }
    // if initialVolume changes upstream, this composable never finds out
}

If a value can change from outside, it needs to be a parameter that's read fresh on every recomposition — not seeded into local state once. Reach for remember(key) if you genuinely need to reset local state when an identity changes, but treat that as the exception, not the default.

6. Let stability do the performance work for you

Compose skips recomposition for composables whose inputs are stable and unchanged — but only if the compiler can actually prove that. Data classes with var properties, or classes holding List/Map (which the compiler treats as unstable by default), silently opt your composable out of that optimization. In practice this means:

  • Prefer val and immutable collections (ImmutableList from kotlinx.collections.immutable, or plain List treated as read-only by convention) in state holders that back Compose UI.
  • Run the Compose compiler's stability report during code review on performance-sensitive screens — it's often surprising which classes are silently unstable.
  • Use @Immutable or @Stable annotations deliberately, not as a blanket fix, once you've confirmed the class actually behaves that way. None of this matters for a static settings screen. It matters a lot for a list of hundreds of IoT devices updating live sensor readings.

The underlying principle

Every one of these rules is really one idea wearing different clothes: a well-designed composable is a pure function of its inputs, and every design decision should make that easier to see, not harder. Hoist the state, expose slots instead of flags, pass the modifier through, keep the function small enough to preview in isolation. Do that consistently across a codebase and the fifteen-parameter God Composable stops happening — not because anyone bans it, but because the alternative is just easier to write.

More from this blog

M

mohamedebrahim96

5 posts

I'm a software engineer who is passionate about building android applications with Kotlin and Java. Creating technology to elevate people, and building community.