← All articles

Modifiers vs Single-Child Wrapper Composables


One-line definition: When an API decorates a single child, prefer Modifier.foo() over a Foo { content } wrapper — modifiers preserve node identity and chain flatly; single-child wrappers break both.


1. Why it exists (the problem it solves)

Every @Composable that takes a content: @Composable () -> Unit slot inserts a new node into the composition tree. When that wrapper is conditionally applied (the typical case for visual effects), the child’s position in the tree changes whenever the condition flips. Compose identifies composables by their position in the source tree, so this position change breaks component identity: remember state, animation progress, decoded bitmaps, scroll positions — all reset.

Modifiers don’t insert a node. Toggling a modifier is a property update on the existing node — same identity, same remembered state.

The rule solves four problems at once:

(Prerequisite: compose-component-identity.md.)


2. The mental model: create vs decorate

A composable creates a thing. A modifier decorates a thing.

A “thing” = a node with its own identity, size, position, and possibly children. “Decorate” = change appearance, sizing, or behavior without changing what the thing is or what’s inside it.

The decision question to ask yourself: Am I creating a new node, or modifying an existing one?

If creating → composable. If modifying → modifier.


3. The three axes

Walk down the list. First “yes” forces a composable. If all three say “no”, it’s a modifier.

Axis 1 — Are you arranging more than one child?

If the API exists to put things next to / on top of / around each other → composable. A modifier sees one node. Examples: Row, Column, Box, LazyColumn, Scaffold.

Axis 2 — Do children need a special scope?

Some APIs only exist inside a parent: Modifier.weight lives on RowScope, Modifier.align lives on BoxScope. Receiver-typed slot lambdas require a composable — modifiers can’t introduce a receiver scope. Examples: Box { Text(Modifier.align(Center)) }, Row { Spacer(Modifier.weight(1f)) }.

Axis 3 — Are you composing other @Composable content alongside the child?

A tooltip body, a badge label, a “Pro” pill — anything that is itself a composable subtree. Use named slots, not single-child wrappers. Painting extra pixels does not count. Drawing is decoration → still a modifier.

If all three say “no” → it is decoration of one node → modifier.


You learned that a composable’s identity is fixed by its position in the source code. Two consequences:

The single sentence to remember: a modifier never threatens the identity of what it decorates; a wrapper always does.


5. Code examples

a. The motivating example (Cloudy)

// ✅ Correct — decoration expressed as a modifier.
GlideImage(
    url,
    modifier = Modifier.cloudy(radius = 15)
)

// ❌ Wrong — decoration disguised as a single-child wrapper.
Cloudy(radius = 15) {
    GlideImage(url)
}

b. Conditional toggle — the identity argument

// ❌ Wrapper toggle: when `blur` flips, GlideImage's parent changes
//    from Cloudy to whatever's outside. Tree position changes →
//    identity broken → decode cache, crossfade, animation reset.
if (blur) Cloudy(radius = 15) { GlideImage(url) }
else GlideImage(url)

// ✅ Modifier toggle: same GlideImage node either way. The modifier
//    chain just gets a property update; identity preserved, state kept.
GlideImage(
    url,
    modifier = Modifier.then(if (blur) Modifier.cloudy(15) else Modifier)
)

c. Minimal — press-border effect

// ❌ Wrapper version
//    - inserts a Box layout node
//    - callers are forced into nesting
//    - cannot combine with other modifiers in a flat chain
//    - if conditionally applied, breaks the inner content's identity
@Composable
fun PressBordered(enabled: Boolean, content: @Composable () -> Unit) {
    val interactionSource = remember { MutableInteractionSource() }
    val pressed by interactionSource.collectIsPressedAsState()
    Box(
        modifier = Modifier.border(
            width = if (pressed && enabled) 2.dp else 0.dp,
            color = Color.Red,
        )
    ) { content() }
}

// ✅ Modifier version
//    - no extra layout node
//    - chains flat with .padding, .clickable, etc.
//    - conditional application is safe; toggles preserve identity
fun Modifier.pressBorder(enabled: Boolean): Modifier = composed {
    val interactionSource = remember { MutableInteractionSource() }
    val pressed by interactionSource.collectIsPressedAsState()
    this
        .hoverable(interactionSource) // wires pointer events to the source
        .border(
            width = if (pressed && enabled) 2.dp else 0.dp,
            color = Color.Red,
        )
}

// Usage — flat, ordered, no forced nesting:
Text(
    "Click me",
    modifier = Modifier
        .padding(8.dp)
        .pressBorder(enabled = true)
        .clickable { /* ... */ }
)

d. Wrapper anti-pattern — shimmer

// ❌ Wrapper version: every shimmering element gets an extra Box.
//    In a LazyColumn of 100 placeholder rows, that is 100 extra
//    LayoutNodes whose only job is to host the shimmer paint.
//    When `isLoading` flips, content() goes from "child of Shimmer"
//    to "direct child of parent" — identity broken, child state reset.
@Composable
fun Shimmer(isLoading: Boolean, content: @Composable () -> Unit) {
    if (!isLoading) { content(); return }
    val transition = rememberInfiniteTransition()
    val offset by transition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)),
    )
    Box(
        modifier = Modifier.drawWithContent {
            drawContent()
            drawRect(
                brush = Brush.linearGradient(
                    colors = listOf(Color.Transparent, Color.White.copy(alpha = 0.4f), Color.Transparent),
                    start = Offset(offset - 200f, 0f),
                    end = Offset(offset, size.height),
                ),
                blendMode = BlendMode.SrcAtop,
            )
        }
    ) { content() }
}

The correct production-grade replacement is a Modifier.shimmer() built with Modifier.Node. Modifier.Node is its own concept — covered in a future lesson. For this concept, the takeaway is only: this should be a modifier, not a wrapper.


6. Worked decisions (mental-model practice)

These ran through the three axes during the lesson:

  1. RoundedCorners(radius = 12.dp) { Image(...) } → modifier. One child, no scope, no extra @Composable content. → Modifier.clip(RoundedCornerShape(12.dp)).
  2. Right-edge fade gradient when child is wider than 200dp → modifier. Measuring the child and drawing pixels both stay in the modifier pipeline. No extra composable content.
  3. Overlay a “Pro” pill (with its own text/styling) on a child → composable. The pill is itself a composable subtree → axis 3 fires. Use named slots: WithBadge(badge = { ProPill() }) { Avatar(...) }.
  4. if (isPremium) GoldFrame { Avatar(user) } else Avatar(user) → redesign as a modifier so identity survives the toggle: Avatar(user, modifier = if (isPremium) Modifier.goldFrame() else Modifier).

7. Key APIs


8. Common mistakes

  1. Writing @Composable fun MyEffect(content: @Composable () -> Unit) for a visual effect with exactly one child.
  2. Using Box { content() } purely to attach a modifier — apply the modifier directly to the child instead.
  3. Wrapping just to provide a CompositionLocal — use CompositionLocalProvider directly.
  4. Conditionally wrapping a child with an effect — toggling the wrapper resets the child’s identity.
  5. Naming a modifier with PascalCase / a noun (Modifier.Cloudy(15)) — modifiers are decorations; use lowercase verb/adjective form (Modifier.cloudy(15)).

9. Best practices


Already covered:

Up next, each as its own focused session:


11. Quick recall