When (and When Not) to Write a Custom Modifier.Node
One-line definition: A decision framework for choosing between built-in modifiers, lambda-form modifiers, composable wrappers, effects, and full Modifier.Node + ModifierNodeElement — grounded in Compose’s internals (two trees, three phases, slot table, phase-tagged state reads) and demonstrated with real-world examples where built-in modifiers genuinely cannot do the job.
This file is the companion to compose-custom-modifiers.md. That one covers the how (the three pieces, lifecycle, code shape). This one covers the when and why.
1. Why this exists (the problem it solves)
You can build most UIs by chaining built-in modifiers. But certain real-world behaviors — impression tracking, animations without recomposition, sensor-bound effects, gestures that share state with draw — either can’t be expressed at all with built-ins, or can only be expressed at a significant performance / readability cost.
The cost of reaching for Modifier.Node too early: three extra types, lifecycle reasoning, invalidation rules.
The cost of reaching for it too late: jank in lists, recomposition cascades, fragile state, battery drain.
This file is the decision framework that tells the two apart.
2. The decision tree
Run these questions in order before reaching for Modifier.Node:
1. Can I build this from existing modifiers chained together?
YES → chain them, stop here.
NO → continue.
2. Is this just composable UI I want to reuse?
YES → write a @Composable function, stop here.
NO → continue.
3. Is this used at exactly one call site?
YES → use the lambda forms:
- Modifier.drawBehind { } / drawWithCache { }
- Modifier.layout { }
- Modifier.pointerInput(key) { }
- Modifier.onGloballyPositioned { }
Stop here.
NO → continue.
4. Is the behavior stateless and the lambda forms acceptable?
YES → the lambda forms still work; hoist into a `val` / extension.
NO → continue.
5. Do you need ANY of:
- Lifecycle hooks (onAttach/onDetach)?
- Long-lived state that must survive param updates?
- Animation that runs without recomposing?
- CompositionLocal read inside a modifier?
- Skippability based on param equality in a hot list?
- Multiple pipelines at once (e.g. input + draw)?
YES → Modifier.Node is the right answer.
NO → reconsider whether you need it at all.
3. The “what’s the lambda alternative?” check
For most “I need a custom modifier” thoughts, a built-in lambda modifier already does the job. Map the need to the right one first:
| Goal | First-reach API |
|---|---|
| One-off custom drawing | Modifier.drawBehind { } |
| Custom drawing with caching | Modifier.drawWithCache { } |
| One-off custom layout math | Modifier.layout { } |
| One-off gesture | Modifier.pointerInput(key) { } |
| Observe position | Modifier.onGloballyPositioned { } |
| Observe size | Modifier.onSizeChanged { } |
| Transform without remeasure | Modifier.graphicsLayer { } |
These are not Modifier.Node — they’re already optimized built-ins. Promote to a Node only when reuse + hot-path concerns add up.
4. The internals you need to make the decision
4.1 Compose has TWO trees
@Composable code produces:
┌───────────────────────────┐ ┌──────────────────────┐
│ COMPOSITION TREE │ ──────► │ UI / LAYOUT TREE │
│ │ │ │
│ Tracked by SLOT TABLE │ │ Tracked by │
│ (positional array) │ │ LayoutNode identity│
└───────────────────────────┘ └──────────────────────┘
Where `remember` lives Where `Modifier.Node` lives
You write @Composable code. Compose runs it. The output isn’t pixels — it’s a tree of LayoutNodes (the actual UI elements, like DOM nodes).
Two trees, two storage systems. remember lives in the composition tree (tracked positionally). Modifier.Node lives attached to LayoutNodes in the UI tree (tracked by identity).
4.2 The slot table — why remember can be fragile
Every remember { } claims a numbered “page” in Compose’s notebook. Pages are assigned by walk order, not by name.
@Composable
fun Card() {
val a = remember { "hello" } // page 47
val b = remember { 42 } // page 48
Text("hi") // pages 49, 50, ...
}
On recomposition, Compose walks the same path. Page 47 returns "hello" — same instance. That’s how remember “remembers.”
The slot has no name — only an index in the walk order. Anything that changes walk order changes which slot is “page 47.”
Common failure mode (forgetting key in list):
items.forEach { item -> // ← no key!
ThumbnailWithRing(item.progress, item.title)
}
Insert a new item at position 0:
Before: After insert at front:
Slot 100: Animatable(0.4f) Slot 100: Animatable(???)
Slot 101: Animatable(0.7f) Slot 101: Animatable(0.4f) ← A matched to B's slot
Slot 102: Animatable(0.7f)
Fix: items.forEach(key = { it.id }) { }. Forgetting it is a real, common bug.
A Modifier.Node is attached to a LayoutNode, whose identity = position in parent + type. Matching is more robust than slot-table walks.
4.3 Compose has THREE phases + an “attachment” event
┌─────────────────────────────────────────────────────────────┐
│ 1. COMPOSITION — @Composable functions run │
│ State read here → invalidate → another COMPOSITION │
├─────────────────────────────────────────────────────────────┤
│ 2. LAYOUT — LayoutNodes measure + place children │
│ State read here → invalidate → another LAYOUT │
├─────────────────────────────────────────────────────────────┤
│ 3. DRAW — LayoutNodes paint pixels │
│ State read here → invalidate → another DRAW │
└─────────────────────────────────────────────────────────────┘
Attachment is separate: a LayoutNode is attached when it’s part of the live, drawn tree on screen. It can be:
- Composed but not attached (parent didn’t use it).
- Attached and drawing (visible).
- Detached but
@Composablestill in composition (NavHost back stack — state preserved, LayoutNode not on screen).
| Event | Trigger |
|---|---|
DisposableEffect runs |
@Composable finished composing |
DisposableEffect.onDispose |
@Composable left composition / key changed |
Node.onAttach() |
LayoutNode attached to live UI tree |
Node.onDetach() |
LayoutNode left live UI tree |
For battery-sensitive subscriptions (GPS, BLE, sensors), onAttach / onDetach is the accurate hook. DisposableEffect runs earlier (during composition) and stays alive in NavHost back stack — meaning GPS keeps draining while user is on a different screen.
4.4 State reads tag the phase they happen in
When you read a mutableStateOf value, Compose’s runtime records which phase you were in. Writes invalidate only that phase.
- Read in
@Composablebody → tags composition (most expensive invalidation). - Read in
Modifier.layout { },graphicsLayer { }, or Node’smeasure()→ tags layout. - Read in
Modifier.drawBehind { }or Node’sdraw()→ tags draw (cheapest).
This is the single most powerful idea for writing fast Compose. A Modifier.Node makes “all state reads happen in a non-composition phase” the natural default.
4.5 CompositionLocal lookup
A CompositionLocal flows down the composition tree by ancestor lookup (like CSS inheritance or React Context).
- The live stack only exists DURING composition. Per-position snapshots are cached on LayoutNodes.
- Reading
.currentrequires being inside a@Composablefunction. - A plain
Modifierextension cannot read it (Kotlin compile error). - A wrapper composable inserts a
BoxLayoutNode (extra measure pass + identity boundary). Modifier.Node+CompositionLocalConsumerModifierNode+currentValueOf(local)reads it cleanly — uses the cached snapshot at the Node’s tree position.
5. The 5 upgrade triggers (each with a real scenario)
Trigger 1 — Allocation pressure in lists
Scenario: Gmail inbox. Every email row has swipe-to-archive. ~12 rows visible, scrolling at 60fps.
LazyColumn {
items(emails, key = { it.id }) { email ->
Box(modifier = Modifier.pointerInput(email.id) {
detectHorizontalDragGestures(
onDragEnd = { onArchive(email.id) },
onHorizontalDrag = { _, dx -> offsetX += dx },
)
}) { EmailRow(email) }
}
}
What hurts:
- Each
pointerInput { }lambda is a freshFunction2object — compiler can sometimes memoize butemail.idcapture often defeats it. Modifier.pointerInputallocates a small (key, lambda) wrapper Element each call.- Cumulative allocations cause GC pressure during scroll → occasional frame drops on mid-tier devices.
- More importantly: if
key(e.g.email.id) changes when a row recycles, the gesture-detector coroutine tears down and relaunches.
Node fix: Modifier.swipeToArchive(emailId, onArchive) backed by a data class Element. Equality check skips the modifier entirely when args unchanged.
Trigger 2 — State must survive param updates
Scenario: YouTube thumbnail card with animated progress ring. Animation mid-flight when user edits the title (parent recomposes).
remember { Animatable(...) } survives parent recomposition normally — but is positional (slot-table) and can lose state if siblings shift or key is missing in a list.
Modifier.Node state is bound to the LayoutNode’s identity, which is matched by tree position and type. More robust than slot-table walks.
Trigger 3 — Real attachment lifecycle
Scenario: Maps screen with “you are here” dot. GPS should subscribe only while map is on screen.
DisposableEffect(Unit) { subscribe(); onDispose { cancel() } } fires during composition. In NavHost back stack, the Map screen stays composed (for state preservation) — so GPS keeps running while user is 3 screens deep elsewhere. Battery bug.
Modifier.Node.onAttach / onDetach are tied to actual LayoutNode attachment. Fires onDetach when navigating away.
Trigger 4 — Multiple pipelines sharing state
Scenario: Instagram-style press-to-zoom — touch a photo, scale to 1.05× and dim overlay until release.
Lambda version reads pressed: State<Boolean> in @Composable body for animateFloatAsState(if (pressed) 1.05f else 1f). Read happens during composition → write triggers full recomposition of the wrapping composable per animation frame.
Node version reads pressed only inside draw() → write invalidates only draw. 60fps zoom animation with zero recompositions.
Trigger 5 — Read a CompositionLocal from a modifier
Scenario: WhatsApp chat bubble border that picks color from current Material theme.
- Plain
Modifier.themedBorder()extension can’t readLocalContentColor.current(compile error, not @Composable). - Wrapper composable inserts a
Box(extra LayoutNode, identity boundary). Modifier.composed { }works but allocates per call site per recomposition.Modifier.Node + CompositionLocalConsumerModifierNode+currentValueOf(LocalContentColor)is the only approach that’s cheap, clean, and avoids inserting layout nodes.
6. Anti-patterns (when you reached for Node too eagerly)
AP 1 — A Node that’s really an effect
private class ScreenViewedNode(val name: String) : Modifier.Node() {
override fun onAttach() { analytics.log("screen_viewed", name) }
}
Doesn’t implement any pipeline interface — no draw, no measure, no input. Just using onAttach as an event hook.
Fix: it’s just an effect.
@Composable
fun ScreenViewLogger(name: String) {
LaunchedEffect(name) { analytics.log("screen_viewed", name) }
}
Rule: if your Node doesn’t decorate the UI element via some pipeline, it’s not a modifier — it’s an effect.
AP 2 — A Node to share a Modifier chain
Wrong: a CardStyleNode with 50 lines.
Right: a plain extension function.
fun Modifier.cardStyle() = this
.clip(RoundedCornerShape(12.dp))
.shadow(2.dp, RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
Each built-in modifier already gets equality/skipping benefits. Stable chains are interned by Compose. Wrapping them in a custom Node = a fourth layer over three already-optimized Nodes.
AP 3 — A Node to share composable content
Modifiers decorate existing LayoutNodes. They cannot add new LayoutNodes for visible content. If you need new text/icons/shapes, you need a composable function. Drawing text via drawText inside a Node’s draw() reinvents Text badly (no accessibility, no RTL, no font fallback).
7. Worked Example 1 — Modifier.onVisible { } (impression tracking)
Scenario
Feed app. Log “user saw this post” exactly once, when the card has been ≥50% visible for ≥500ms continuously. Used on every feed item.
Why no built-in works
A. Modifier.onGloballyPositioned { } alone — gives ~60 callbacks/sec during scroll but no timer, no single-fire, no cancellation on scroll-off.
B. LaunchedEffect(Unit) { delay(500); onSeen() } — fires after 500ms in composition, not 500ms visible. Posts in LazyColumn prefetch window get false impressions.
C. Compose them together — 30+ lines of state choreography per call site, 2+ lambdas + 2+ State objects allocated per item per recomposition in a 1000-item feed.
Node version
fun Modifier.onVisible(
thresholdMs: Long = 500L,
minVisibleFraction: Float = 0.5f,
onSeen: () -> Unit,
): Modifier = this then OnVisibleElement(thresholdMs, minVisibleFraction, onSeen)
private data class OnVisibleElement(
val thresholdMs: Long,
val minVisibleFraction: Float,
val onSeen: () -> Unit,
) : ModifierNodeElement<OnVisibleNode>() {
override fun create() = OnVisibleNode(thresholdMs, minVisibleFraction, onSeen)
override fun update(node: OnVisibleNode) {
node.thresholdMs = thresholdMs
node.minVisibleFraction = minVisibleFraction
node.onSeen = onSeen
}
}
private class OnVisibleNode(
var thresholdMs: Long,
var minVisibleFraction: Float,
var onSeen: () -> Unit,
) : Modifier.Node(),
GlobalPositionAwareModifierNode, // gives us onGloballyPositioned
LayoutAwareModifierNode { // gives us onRemeasured
private var size = IntSize.Zero
private var fired = false // single-fire guard, lives on Node
private var job: Job? = null // pending 500ms timer
override fun onRemeasured(size: IntSize) {
this.size = size
}
override fun onGloballyPositioned(coords: LayoutCoordinates) {
if (fired) return
val fraction = visibleFraction(coords, size)
if (fraction >= minVisibleFraction) {
// Visible enough — start timer if not already running.
if (job == null) {
job = coroutineScope.launch {
delay(thresholdMs)
fired = true
onSeen()
}
}
} else {
// Visibility dropped before threshold → cancel pending fire.
job?.cancel(); job = null
}
}
override fun onDetach() {
// coroutineScope auto-cancels, but explicit guards against detach/reattach races.
job?.cancel(); job = null
}
private fun visibleFraction(coords: LayoutCoordinates, size: IntSize): Float {
if (!coords.isAttached || size.width == 0 || size.height == 0) return 0f
val r = coords.boundsInWindow()
val win = coords.findRootCoordinates().size
val vw = (r.right.coerceAtMost(win.width.toFloat()) - r.left.coerceAtLeast(0f)).coerceAtLeast(0f)
val vh = (r.bottom.coerceAtMost(win.height.toFloat()) - r.top.coerceAtLeast(0f)).coerceAtLeast(0f)
return (vw * vh) / (size.width.toFloat() * size.height.toFloat())
}
}
Why this is uniquely Node territory
It’s the combination — coordinates + size + timer + lifecycle — that has no single built-in. Composing built-ins works but pays allocation cost in lists + readability cost at every call site. The Node has a coroutineScope tied to attachment, internal fired state surviving parent recompositions, and Element equality giving free skippability.
8. Worked Example 2 — Modifier.shake(trigger) (error feedback)
Scenario
Login screen. Wrong password → password field shakes horizontally for ~400ms. Used on every form field across the app.
Why no built-in works
A. Modifier.offset(x.dp) + animateFloatAsState — interpolates one value; doesn’t oscillate. More importantly, offsetX.dp is read in the @Composable body → write invalidates composition → PasswordField recomposes ~24 times across the 400ms animation. If TextField contents aren’t fully skippable, whole TextField recomposes 24× per shake.
B. Modifier.offset { IntOffset(offsetX.roundToPx(), 0) } (lambda) — read happens in layout phase, fixes the per-frame composition. But: animateFloatAsState is still composition-scoped (needs @Composable). Can’t be packaged as a plain Modifier.shake() because the implementation must live in a @Composable.
C. Wrapper composable — works but inserts a Box LayoutNode (extra measure pass, identity boundary). Can’t compose with existing modifier chains on the field.
Node version
fun Modifier.shake(trigger: Int): Modifier = this then ShakeElement(trigger)
private data class ShakeElement(
val trigger: Int,
) : ModifierNodeElement<ShakeNode>() {
override fun create() = ShakeNode(trigger)
override fun update(node: ShakeNode) {
// Only restart the shake when trigger actually changes.
if (node.trigger != trigger) {
node.trigger = trigger
node.startShake()
}
}
}
private class ShakeNode(var trigger: Int) : Modifier.Node(), LayoutModifierNode {
// State-backed offset on the Node. Read inside measure() → write invalidates LAYOUT only.
private var offsetX by mutableFloatStateOf(0f)
private var job: Job? = null
fun startShake() {
job?.cancel()
job = coroutineScope.launch {
val pattern = listOf(10f, -10f, 6f, -6f, 0f)
for (target in pattern) {
animate(offsetX, target, animationSpec = tween(60)) { v, _ ->
offsetX = v // state write → invalidates LAYOUT only
}
}
}
}
override fun MeasureScope.measure(
measurable: Measurable, constraints: Constraints,
): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
// Read of offsetX happens here, in the placement step of LAYOUT.
placeable.place(offsetX.roundToInt(), 0)
}
}
override fun onDetach() { job?.cancel(); job = null }
}
Why this is uniquely Node territory
Any composable-scoped animation API (animateFloatAsState, Transition) routes its trigger through composition. To get zero recompositions during animation, you must own the Animatable/animation on a Node and read its value in a non-composition phase. That’s what makes 60fps animations feasible on every list item simultaneously.
9. Key APIs
Pipeline interfaces (mix into your Node as needed)
DrawModifierNode—draw(ContentDrawScope)LayoutModifierNode—measure(Measurable, Constraints): MeasureResultPointerInputModifierNode—onPointerEvent(...)GlobalPositionAwareModifierNode—onGloballyPositioned(LayoutCoordinates)LayoutAwareModifierNode—onRemeasured(IntSize),onPlaced(...)CompositionLocalConsumerModifierNode—currentValueOf(local)SemanticsModifierNode— accessibility infoDelegatingNode— compose other Nodes inside yours
Lifecycle and scope
onAttach()/onDetach()— tied to LayoutNode attachment, not compositioncoroutineScope— Node-bound CoroutineScope, auto-cancelled on detach
State backing for auto-invalidation
mutableStateOf,mutableFloatStateOf,mutableIntStateOfas Node fields- Read inside
measure()→ invalidates layout on write - Read inside
draw()→ invalidates draw on write - Read inside a
@Composablebody → invalidates composition on write (typically not what you want)
Manual invalidation (when you can’t use state backing)
invalidateDraw()invalidateMeasurement()invalidatePlacement()
Coordinates
LayoutCoordinates.boundsInWindow(),boundsInRoot(),findRootCoordinates()Constraints.offset(horizontal, vertical),Constraints.copy(maxWidth = ...),constraints.constrainWidth(x)
10. Common mistakes
- Using a Node where an effect would do. No pipeline interface implemented → it’s an effect, not a modifier.
- Using a Node to share a Modifier chain. Plain extension function returning a chain is enough.
- Using a Node to share composable content. Use a
@Composablefunction. - Reading state in
@Composablebody when you could read it inmeasure()/draw()/graphicsLayer { }lambda. Composition-phase invalidation is the most expensive. - Forgetting
key = { it.id }initems {}/forEach. Causesrememberto lose state on list reorder. (Modifier.Nodes don’t share this problem to the same degree because LayoutNode identity is more robust.) - Subscribing to GPS/BLE/sensors in
DisposableEffect. Stays active in NavHost back stack. UseModifier.Node.onAttach/onDetach. - Starting work in
create()instead ofonAttach().create()runs before the Node is attached. - Using a non-data-class Element. Without
equals/hashCode, every recomposition triggersupdate()or recreation. - Building a new Node inside
update().update()is supposed to mutate the existing Node in place. - Reading
LocalContentColor.currentin a plainModifierextension. Won’t compile. UseCompositionLocalConsumerModifierNode+currentValueOf(LocalContentColor).
11. Best practices
- Default to built-ins. Chain them, compose them, wrap them in extension functions.
- Promote to lambda-form modifiers (
drawBehind,layout,pointerInput) for one-off custom behavior. - Promote to
Modifier.Nodewhen ANY of: reused at 3+ sites, used in a hot list, animation must run without recomposition, needs attachment lifecycle, reads CompositionLocal, or mixes input + draw + layout pipelines. - Make the Element a data class with all modifier params as
valfields. - Back any state field whose write should redraw/relayout with
mutableStateOf— auto-invalidates the right phase. - Read state in the phase you want invalidated.
measure()→ layout.draw()→ draw. Never@Composablebody if you can avoid it. - Cancel in
onDetachwhatever you started inonAttach— symmetric cleanup. - One effect per modifier. Don’t pack four behaviors into one Node.
- For battery-sensitive subscriptions: prefer
onAttach/onDetachoverDisposableEffect.
12. Related concepts / what to learn next
Already covered:
compose-custom-modifiers.md— the how (3 pieces, lifecycle, code shape, draw/layout/CompositionLocal examples).compose-modifiers-vs-single-child-wrappers.md— why wrapping in aBoxis the wrong escape hatch.compose-component-identity.md— why identity preservation matters so much.compose-measure-phase.md— layout-phase context.
Queued examples (in order of next session):
Modifier.onVisible { }— fully wire it into aLazyColumn, simulate scroll, verify with logs.Modifier.shake(trigger)— wire into a form, verify with Layout Inspector that recompositions stay at 1 while field shakes.Modifier.parallaxTilt(strength)— gyroscope subscription bound to attachment, draw-phase transform.
Next concepts after Modifier.Node mastery:
PointerInputModifierNode— gesture deep-dive.DelegatingNode— compose existing Nodes inside your own (reuse pattern).SubcomposeLayout— when a parent must measure something then compose something else.LookaheadScope— pre-measurement for shared-element transitions.- Intrinsic measurements —
minIntrinsicWidth/maxIntrinsicHeightthrough modifiers.
13. Quick recall (10 bullets)
- Two trees: composition tree (slot table, where
rememberlives) and UI/layout tree (LayoutNodes, whereModifier.Nodelives). Node identity is more robust than slot-table walks. - Three phases + attachment: composition → layout → draw. Attachment is separate from composition. A LayoutNode can be composed-but-not-attached (NavHost back stack).
- State reads tag the phase they happen in. Reads in
@Composablebody → composition invalidation (most expensive). Reads inmeasure()/draw()→ layout/draw invalidation (cheap). - Decision order: chain built-ins → @Composable function → lambda-form modifier → Modifier.Node. Promote only when reuse + hot-path + lifecycle/state needs add up.
- 5 upgrade triggers: allocation pressure in lists, state must survive param updates, real attachment lifecycle, multiple pipelines sharing state, reading CompositionLocal from a modifier.
- 3 anti-patterns: Node as effect, Node to share a Modifier chain, Node to share composable content.
onAttach/onDetachare tied to LayoutNode attachment — the right hook for battery-sensitive subscriptions.DisposableEffectis tied to composition and stays alive in NavHost back stack.Modifier.onVisible(Example 1) is uniquely Node territory because no single built-in combines coordinates + size + timer + attachment lifecycle.Modifier.shake(Example 2) is uniquely Node territory because any composable-scoped animation API routes through composition; zero-recomposition animation requires owning theAnimatableon a Node and reading it in layout/draw phase.- Element equality drives skipping — data class Element with same args next frame → Compose calls
update()(or skips entirely). One Node lives acrossupdate()calls, preserving identity for animations, coroutines, decoded resources.