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:
- Identity preservation when effects are toggled or conditionally applied (the dominant reason).
- Tree weight — fewer layout nodes, fewer measure/place passes.
- Flat composition —
Modifier.a().b().c()reads top-to-bottom in application order; nested wrappers hide order in indentation. - First-class values — modifiers can be stored in variables, passed around, conditionally combined with
Modifier.then(...). Wrappers can’t.
(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.
4. Why this matters — link to identity
You learned that a composable’s identity is fixed by its position in the source code. Two consequences:
- Modifier change → property update on the same node → identity unchanged →
remembersurvives, animations continue. - Wrapper change (e.g.,
if (cond) Foo { Child } else Child) → child’s parent flips betweenFooand the original parent → child’s tree position changes → identity broken → state lost.
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:
RoundedCorners(radius = 12.dp) { Image(...) }→ modifier. One child, no scope, no extra@Composablecontent. →Modifier.clip(RoundedCornerShape(12.dp)).- 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.
- 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(...) }. 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
Modifier— the chainable decorator type.Modifier.then(other)— append; the basis of chaining and conditional combination (Modifier.then(if (x) Modifier.foo() else Modifier)).Modifier.composed { ... }— convenience for writing a stateful modifier that needs composable scope (remember, etc.). Easy to write; allocates per call site. Fine for learning; replace withModifier.Nodefor production. (Modifier.Node = future lesson.)content: @Composable () -> Unit— the slot signature. When this is the only parameter and you accept exactly one child, scrutinize whether it should be a modifier instead.- Receiver-scoped slots —
BoxScope,RowScope,ColumnScope. These are the legitimate reason for a slot to exist (axis 2).
8. Common mistakes
- Writing
@Composable fun MyEffect(content: @Composable () -> Unit)for a visual effect with exactly one child. - Using
Box { content() }purely to attach a modifier — apply the modifier directly to the child instead. - Wrapping just to provide a
CompositionLocal— useCompositionLocalProviderdirectly. - Conditionally wrapping a child with an effect — toggling the wrapper resets the child’s identity.
- Naming a modifier with PascalCase / a noun (
Modifier.Cloudy(15)) — modifiers are decorations; use lowercase verb/adjective form (Modifier.cloudy(15)).
9. Best practices
- Default to a modifier. Force yourself to justify any wrapper.
- Run the three-axis check before designing any new component API.
- For toggleable effects, always prefer
Modifier.then(if (cond) Modifier.foo() else Modifier)overif (cond) Foo { ... } else ...— the former preserves identity. - Expose decorations from your design system as modifiers (
Modifier.elevatedCard()), not wrappers. - Keep modifier names lowercase, verb/adjective form:
.shimmer(),.cloudy(...),.pressBorder(...). - One modifier, one effect — don’t bundle five unrelated decorations into a single modifier; let callers chain.
- Document chain-order sensitivity in modifier KDoc when it matters (
Modifier.padding().background()≠Modifier.background().padding()).
10. Related concepts / what to learn next
Already covered:
- Component identity (
compose-component-identity.md) — the foundation for why modifiers preserve state and wrappers don’t.
Up next, each as its own focused session:
Modifier.NodeandModifierNodeElement— the modern, allocation-free way to write stateful modifiers (replacescomposed { }).- The three modifier node interfaces:
DrawModifierNode,LayoutModifierNode,PointerInputModifierNode— the pipelines a modifier hooks into. SubcomposeLayout— when a parent measures something and then composes something else based on the result; the rare legitimate reason a wrapper composable cannot be replaced by a modifier.LookaheadScope— pre-measurement for shared-element transitions and predictive layout.- Intrinsic measurements — how
min/maxIntrinsicWidth/Heightflow through modifiers and layouts.
11. Quick recall
- A composable creates a node. A modifier decorates a node.
- Three axes force a composable: (1) arranging more than one child, (2) child needs a receiver scope, (3) you must compose extra
@Composablecontent alongside the child. Otherwise → modifier. - Painting extra pixels is decoration → modifier. Composing extra
@Composablecontent is structure → composable. - Toggling a wrapper changes the child’s tree position → identity loss →
rememberand animation state reset. - Toggling a modifier is a property update on the same node → identity preserved.
- Modifiers chain flat and ordered; wrappers nest and hide application order.
- A
content: @Composable () -> Unitslot earns its keep only when it holds 0..N children, exposes a receiver scope, or is one of multiple named slots. - The smell:
Cloudy(15) { ... }. The cure:Modifier.cloudy(15). - Worked answers from this lesson:
RoundedCorners→ modifier; right-edge fade → modifier; “Pro” pill overlay → composable;GoldFrametoggle → modifier.