# 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.
 
```kotlin
// 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:
 
```kotlin
@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:
 
```kotlin
@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.
 
```kotlin
@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?
 
```kotlin
@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.
 
```kotlin
// 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.
 
<!--
Suggested Hashnode tags: Android, Jetpack Compose, Kotlin, Mobile Development
Suggested meta description: Practical Jetpack Compose component design patterns — state hoisting, slot APIs, and modifier conventions — from someone shipping large-scale production Android apps.
-->
