← All articles

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:

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.

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).


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:

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.


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 compositionPasswordField 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)

Lifecycle and scope

State backing for auto-invalidation

Manual invalidation (when you can’t use state backing)

Coordinates


10. Common mistakes

  1. Using a Node where an effect would do. No pipeline interface implemented → it’s an effect, not a modifier.
  2. Using a Node to share a Modifier chain. Plain extension function returning a chain is enough.
  3. Using a Node to share composable content. Use a @Composable function.
  4. Reading state in @Composable body when you could read it in measure() / draw() / graphicsLayer { } lambda. Composition-phase invalidation is the most expensive.
  5. Forgetting key = { it.id } in items {}/forEach. Causes remember to lose state on list reorder. (Modifier.Nodes don’t share this problem to the same degree because LayoutNode identity is more robust.)
  6. Subscribing to GPS/BLE/sensors in DisposableEffect. Stays active in NavHost back stack. Use Modifier.Node.onAttach / onDetach.
  7. Starting work in create() instead of onAttach(). create() runs before the Node is attached.
  8. Using a non-data-class Element. Without equals/hashCode, every recomposition triggers update() or recreation.
  9. Building a new Node inside update(). update() is supposed to mutate the existing Node in place.
  10. Reading LocalContentColor.current in a plain Modifier extension. Won’t compile. Use CompositionLocalConsumerModifierNode + currentValueOf(LocalContentColor).

11. Best practices


Already covered:

Queued examples (in order of next session):

  1. Modifier.onVisible { } — fully wire it into a LazyColumn, simulate scroll, verify with logs.
  2. Modifier.shake(trigger) — wire into a form, verify with Layout Inspector that recompositions stay at 1 while field shakes.
  3. Modifier.parallaxTilt(strength) — gyroscope subscription bound to attachment, draw-phase transform.

Next concepts after Modifier.Node mastery:


13. Quick recall (10 bullets)

  1. Two trees: composition tree (slot table, where remember lives) and UI/layout tree (LayoutNodes, where Modifier.Node lives). Node identity is more robust than slot-table walks.
  2. Three phases + attachment: composition → layout → draw. Attachment is separate from composition. A LayoutNode can be composed-but-not-attached (NavHost back stack).
  3. State reads tag the phase they happen in. Reads in @Composable body → composition invalidation (most expensive). Reads in measure()/draw() → layout/draw invalidation (cheap).
  4. Decision order: chain built-ins → @Composable function → lambda-form modifier → Modifier.Node. Promote only when reuse + hot-path + lifecycle/state needs add up.
  5. 5 upgrade triggers: allocation pressure in lists, state must survive param updates, real attachment lifecycle, multiple pipelines sharing state, reading CompositionLocal from a modifier.
  6. 3 anti-patterns: Node as effect, Node to share a Modifier chain, Node to share composable content.
  7. onAttach/onDetach are tied to LayoutNode attachment — the right hook for battery-sensitive subscriptions. DisposableEffect is tied to composition and stays alive in NavHost back stack.
  8. Modifier.onVisible (Example 1) is uniquely Node territory because no single built-in combines coordinates + size + timer + attachment lifecycle.
  9. Modifier.shake (Example 2) is uniquely Node territory because any composable-scoped animation API routes through composition; zero-recomposition animation requires owning the Animatable on a Node and reading it in layout/draw phase.
  10. Element equality drives skipping — data class Element with same args next frame → Compose calls update() (or skips entirely). One Node lives across update() calls, preserving identity for animations, coroutines, decoded resources.